Projet

Général

Profil

0009-auth_oidc-use-generic-related-object-code-53442.patch

Valentin Deniaud, 21 septembre 2022 12:47

Télécharger (24,9 ko)

Voir les différences:

Subject: [PATCH 09/10] auth_oidc: use generic related object code (#53442)

 src/authentic2_auth_oidc/forms.py             | 11 +++
 .../journal_event_types.py                    | 91 -------------------
 .../migrations/0001_initial.py                |  5 +
 .../migrations/0014_auto_20220920_1614.py     | 28 ++++++
 src/authentic2_auth_oidc/models.py            | 27 ++++--
 .../authenticator_detail.html                 | 20 ----
 src/authentic2_auth_oidc/urls.py              | 25 -----
 src/authentic2_auth_oidc/views.py             | 63 +------------
 tests/test_auth_oidc.py                       | 28 +++---
 tests/test_manager_authenticators.py          | 10 +-
 tests/test_manager_journal.py                 | 30 ------
 11 files changed, 85 insertions(+), 253 deletions(-)
 delete mode 100644 src/authentic2_auth_oidc/journal_event_types.py
 create mode 100644 src/authentic2_auth_oidc/migrations/0014_auto_20220920_1614.py
 delete mode 100644 src/authentic2_auth_oidc/templates/authentic2_auth_oidc/authenticator_detail.html
src/authentic2_auth_oidc/forms.py
16 16

  
17 17
from django import forms
18 18

  
19
from authentic2.forms.fields import RoleChoiceField
19 20
from authentic2.forms.widgets import DatalistTextInput, SelectAttributeWidget
20 21

  
21 22
from .models import OIDCClaimMapping, OIDCProvider
......
80 81
        widgets = {
81 82
            'claim': OIDCClaimTextInput,
82 83
        }
84

  
85

  
86
class OIDCRelatedObjectForm(forms.ModelForm):
87
    class Meta:
88
        exclude = ('authenticator',)
89
        field_classes = {'role': RoleChoiceField}
90
        widgets = {
91
            'claim': OIDCClaimTextInput,
92
            'attribute': SelectAttributeWidget,
93
        }
src/authentic2_auth_oidc/journal_event_types.py
1
# authentic2 - versatile identity manager
2
# Copyright (C) 2010-2022 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
from django.utils.translation import gettext_lazy as _
18

  
19
from authentic2.apps.journal.models import EventTypeDefinition
20
from authentic2.apps.journal.utils import form_to_old_new
21

  
22
from .models import OIDCProvider
23

  
24

  
25
class OIDCClaimMappingEvents(EventTypeDefinition):
26
    @classmethod
27
    def record(cls, *, user, session, claim, data=None):
28
        data = data or {}
29
        data.update({'claim_id': claim.pk})
30
        super().record(user=user, session=session, references=[claim.provider], data=data)
31

  
32

  
33
class OIDCClaimMappingCreation(OIDCClaimMappingEvents):
34
    name = 'authenticator.oidc.claim.creation'
35
    label = _('OIDC provider claim creation')
36

  
37
    @classmethod
38
    def get_message(cls, event, context):
39
        (provider,) = event.get_typed_references(OIDCProvider)
40
        claim_id = event.get_data('claim_id')
41
        if context != provider:
42
            return _('creation of claim ({claim_id}) in provider "{provider}"').format(
43
                claim_id=claim_id, provider=provider
44
            )
45
        else:
46
            return _('creation of claim (%s)') % claim_id
47

  
48

  
49
class OIDCClaimMappingEdit(OIDCClaimMappingEvents):
50
    name = 'authenticator.oidc.claim.edit'
51
    label = _('OIDC provider claim edit')
52

  
53
    @classmethod
54
    def record(cls, *, user, session, form):
55
        super().record(
56
            user=user,
57
            session=session,
58
            claim=form.instance,
59
            data=form_to_old_new(form),
60
        )
61

  
62
    @classmethod
63
    def get_message(cls, event, context):
64
        (provider,) = event.get_typed_references(OIDCProvider)
65
        claim_id = event.get_data('claim_id')
66
        new = event.get_data('new') or {}
67
        edited_attributes = ', '.join(new) or ''
68
        if context != provider:
69
            return _('edit claim ({claim_id}) in provider "{provider}" ({change})').format(
70
                claim_id=claim_id,
71
                provider=provider,
72
                change=edited_attributes,
73
            )
74
        else:
75
            return _('edit claim ({claim_id}) ({change})').format(claim_id=claim_id, change=edited_attributes)
76

  
77

  
78
class OIDCClaimMappingDeletion(OIDCClaimMappingEvents):
79
    name = 'authenticator.oidc.claim.deletion'
80
    label = _('OIDC provider claim deletion')
81

  
82
    @classmethod
83
    def get_message(cls, event, context):
84
        (provider,) = event.get_typed_references(OIDCProvider)
85
        claim_id = event.get_data('claim_id')
86
        if context != provider:
87
            return _('deletion of claim ({claim_id}) in provider "{provider}"').format(
88
                claim_id=claim_id, provider=provider
89
            )
90
        else:
91
            return _('deletion of claim %s') % claim_id
src/authentic2_auth_oidc/migrations/0001_initial.py
52 52
                ('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
53 53
                ('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
54 54
            ],
55
            options={
56
                'verbose_name': 'Claim',
57
                'verbose_name_plural': 'Claims',
58
                'default_related_name': 'claim_mappings',
59
            },
55 60
        ),
56 61
        migrations.CreateModel(
57 62
            name='OIDCProvider',
src/authentic2_auth_oidc/migrations/0014_auto_20220920_1614.py
1
# Generated by Django 2.2.26 on 2022-09-20 14:14
2

  
3
import django
4
from django.db import migrations, models
5

  
6

  
7
class Migration(migrations.Migration):
8

  
9
    dependencies = [
10
        ('authentic2_auth_oidc', '0013_auto_20220726_1714'),
11
    ]
12

  
13
    operations = [
14
        migrations.RenameField(
15
            model_name='oidcclaimmapping',
16
            old_name='provider',
17
            new_name='authenticator',
18
        ),
19
        migrations.AlterField(
20
            model_name='oidcclaimmapping',
21
            name='authenticator',
22
            field=models.ForeignKey(
23
                on_delete=django.db.models.deletion.CASCADE,
24
                related_name='claim_mappings',
25
                to='authenticators.BaseAuthenticator',
26
            ),
27
        ),
28
    ]
src/authentic2_auth_oidc/models.py
26 26
from jwcrypto.jwk import InvalidJWKValue, JWKSet
27 27

  
28 28
from authentic2.a2_rbac.utils import get_default_ou
29
from authentic2.apps.authenticators.models import BaseAuthenticator
29
from authentic2.apps.authenticators.models import AuthenticatorRelatedObjectBase, BaseAuthenticator
30 30
from authentic2.utils.misc import make_url, redirect_to_login
31 31
from authentic2.utils.template import validate_template
32 32

  
......
115 115

  
116 116
    type = 'oidc'
117 117
    how = ['oidc']
118
    manager_view_template_name = 'authentic2_auth_oidc/authenticator_detail.html'
119 118
    description_fields = ['show_condition', 'issuer', 'scopes', 'strategy', 'created', 'modified']
120 119

  
121 120
    class Meta:
......
127 126

  
128 127
        return OIDCProviderEditForm
129 128

  
129
    @property
130
    def related_object_form_class(self):
131
        from .forms import OIDCRelatedObjectForm
132

  
133
        return OIDCRelatedObjectForm
134

  
135
    @property
136
    def related_models(self):
137
        return {
138
            OIDCClaimMapping: self.claim_mappings.all(),
139
        }
140

  
130 141
    @property
131 142
    def jwkset(self):
132 143
        if self.jwkset_json:
......
215 226
        return render(request, template_names, context)
216 227

  
217 228

  
218
class OIDCClaimMapping(models.Model):
229
class OIDCClaimMapping(AuthenticatorRelatedObjectBase):
219 230
    NOT_VERIFIED = 0
220 231
    VERIFIED_CLAIM = 1
221 232
    ALWAYS_VERIFIED = 2
......
225 236
        (ALWAYS_VERIFIED, _('always verified')),
226 237
    ]
227 238

  
228
    provider = models.ForeignKey(
229
        to='OIDCProvider', verbose_name=_('provider'), related_name='claim_mappings', on_delete=models.CASCADE
230
    )
231 239
    claim = models.CharField(max_length=128, verbose_name=_('claim'), validators=[validate_template])
232 240
    attribute = models.CharField(max_length=64, verbose_name=_('attribute'))
233 241
    verified = models.PositiveIntegerField(
......
240 248

  
241 249
    objects = managers.OIDCClaimMappingManager()
242 250

  
251
    class Meta:
252
        default_related_name = 'claim_mappings'
253
        verbose_name = _('Claim')
254
        verbose_name_plural = _('Claims')
255

  
243 256
    def natural_key(self):
244 257
        return (self.claim, self.attribute, self.verified, self.required)
245 258

  
......
262 275
        return '<OIDCClaimMapping %r:%r on provider %r verified:%s required:%s >' % (
263 276
            self.claim,
264 277
            self.attribute,
265
            self.provider and self.provider.issuer,
278
            self.authenticator,
266 279
            self.verified,
267 280
            self.required,
268 281
        )
src/authentic2_auth_oidc/templates/authentic2_auth_oidc/authenticator_detail.html
1
{% extends 'authentic2/authenticators/authenticator_detail.html' %}
2
{% load i18n %}
3

  
4
{% block extra-tab-buttons %}
5
  <button aria-controls="panel-claims" aria-selected="false" id="tab-claims" role="tab" tabindex="-1">{% trans "Claims" %}</button>
6
{% endblock %}
7

  
8
{% block extra-tab-list %}
9
  <div aria-labelledby="tab-claims" hidden="" id="panel-claims" role="tabpanel" tabindex="0">
10
    <ul class="objects-list single-links">
11
      {% for claim in object.claim_mappings.all %}
12
        <li>
13
          <a rel="popup" href="{% url 'a2-manager-oidc-edit-claim' authenticator_pk=object.pk pk=claim.pk %}">{{ claim }}</a>
14
          <a rel="popup" class="delete" href="{% url 'a2-manager-oidc-delete-claim' authenticator_pk=object.pk pk=claim.pk %}">{% trans "Remove" %}</a>
15
        </li>
16
      {% endfor %}
17
      <li><a class="add" rel="popup" href="{% url 'a2-manager-oidc-add-claim' authenticator_pk=object.pk %}">{% trans 'Add' %}</a></li>
18
    </ul>
19
  </div>
20
{% endblock %}
src/authentic2_auth_oidc/urls.py
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17 17
from django.conf.urls import url
18
from django.urls import path
19

  
20
from authentic2.apps.authenticators.manager_urls import superuser_login_required
21
from authentic2.decorators import required
22 18

  
23 19
from . import views
24 20

  
......
27 23
    url(r'^accounts/oidc/login/$', views.login_initiate, name='oidc-login-initiate'),
28 24
    url(r'^accounts/oidc/callback/$', views.login_callback, name='oidc-login-callback'),
29 25
]
30

  
31
urlpatterns += required(
32
    superuser_login_required,
33
    [
34
        path(
35
            'authenticators/<int:authenticator_pk>/claim/add/',
36
            views.add_claim,
37
            name='a2-manager-oidc-add-claim',
38
        ),
39
        path(
40
            'authenticators/<int:authenticator_pk>/claim/<int:pk>/edit/',
41
            views.edit_claim,
42
            name='a2-manager-oidc-edit-claim',
43
        ),
44
        path(
45
            'authenticators/<int:authenticator_pk>/claim/<int:pk>/delete/',
46
            views.delete_claim,
47
            name='a2-manager-oidc-delete-claim',
48
        ),
49
    ],
50
)
src/authentic2_auth_oidc/views.py
24 24
from django.contrib import messages
25 25
from django.contrib.auth import REDIRECT_FIELD_NAME
26 26
from django.http import HttpResponseBadRequest
27
from django.shortcuts import get_object_or_404
28 27
from django.urls import reverse
29 28
from django.utils.translation import get_language
30 29
from django.utils.translation import ugettext as _
31
from django.views.generic import CreateView, DeleteView, UpdateView
32 30
from django.views.generic.base import View
33 31

  
34
from authentic2.manager.views import MediaMixin, TitleMixin
35 32
from authentic2.utils import crypto
36 33
from authentic2.utils.misc import authenticate, good_next_url, login, redirect
37 34

  
38
from .forms import OIDCClaimMappingForm
39
from .models import OIDCClaimMapping, OIDCProvider
35
from .models import OIDCProvider
40 36
from .utils import get_provider, get_provider_by_issuer
41 37

  
42 38
logger = logging.getLogger(__name__)
......
350 346

  
351 347

  
352 348
login_callback = LoginCallback.as_view()
353

  
354

  
355
class OIDCProviderMixin(MediaMixin, TitleMixin):
356
    model = OIDCClaimMapping
357

  
358
    def dispatch(self, request, *args, **kwargs):
359
        self.provider = get_object_or_404(OIDCProvider, pk=kwargs.get('authenticator_pk'))
360
        return super().dispatch(request, *args, **kwargs)
361

  
362
    def get_form_kwargs(self):
363
        kwargs = super().get_form_kwargs()
364
        if not kwargs.get('instance'):
365
            kwargs['instance'] = self.model()
366
        kwargs['instance'].provider = self.provider
367
        return kwargs
368

  
369
    def get_success_url(self):
370
        return reverse('a2-manager-authenticator-detail', kwargs={'pk': self.provider.pk}) + '#open:claims'
371

  
372

  
373
class OIDCClaimMappingAddView(OIDCProviderMixin, CreateView):
374
    template_name = 'authentic2/manager/form.html'
375
    title = _('New claim')
376
    form_class = OIDCClaimMappingForm
377

  
378
    def form_valid(self, form):
379
        resp = super().form_valid(form)
380
        self.request.journal.record('authenticator.oidc.claim.creation', claim=form.instance)
381
        return resp
382

  
383

  
384
add_claim = OIDCClaimMappingAddView.as_view()
385

  
386

  
387
class OIDCClaimMappingEditView(OIDCProviderMixin, UpdateView):
388
    template_name = 'authentic2/manager/form.html'
389
    title = _('Edit claim')
390
    form_class = OIDCClaimMappingForm
391

  
392
    def form_valid(self, form):
393
        resp = super().form_valid(form)
394
        self.request.journal.record('authenticator.oidc.claim.edit', form=form)
395
        return resp
396

  
397

  
398
edit_claim = OIDCClaimMappingEditView.as_view()
399

  
400

  
401
class OIDCClaimMappingDeleteView(OIDCProviderMixin, DeleteView):
402
    template_name = 'authentic2/authenticators/authenticator_delete_form.html'
403

  
404
    def delete(self, *args, **kwargs):
405
        self.request.journal.record('authenticator.oidc.claim.deletion', claim=self.get_object())
406
        return super().delete(*args, **kwargs)
407

  
408

  
409
delete_claim = OIDCClaimMappingDeleteView.as_view()
tests/test_auth_oidc.py
184 184
        button_label=name,
185 185
    )
186 186
    provider.full_clean()
187
    OIDCClaimMapping.objects.create(provider=provider, claim='sub', attribute='username', idtoken_claim=True)
188
    OIDCClaimMapping.objects.create(provider=provider, claim='email', attribute='email')
189
    OIDCClaimMapping.objects.create(provider=provider, claim='email', required=True, attribute='email')
190 187
    OIDCClaimMapping.objects.create(
191
        provider=provider,
188
        authenticator=provider, claim='sub', attribute='username', idtoken_claim=True
189
    )
190
    OIDCClaimMapping.objects.create(authenticator=provider, claim='email', attribute='email')
191
    OIDCClaimMapping.objects.create(authenticator=provider, claim='email', required=True, attribute='email')
192
    OIDCClaimMapping.objects.create(
193
        authenticator=provider,
192 194
        claim='given_name',
193 195
        required=True,
194 196
        verified=OIDCClaimMapping.ALWAYS_VERIFIED,
195 197
        attribute='first_name',
196 198
    )
197 199
    OIDCClaimMapping.objects.create(
198
        provider=provider,
200
        authenticator=provider,
199 201
        claim='family_name',
200 202
        required=True,
201 203
        verified=OIDCClaimMapping.VERIFIED_CLAIM,
202 204
        attribute='last_name',
203 205
    )
204
    OIDCClaimMapping.objects.create(provider=provider, claim='ou', attribute='ou__slug')
206
    OIDCClaimMapping.objects.create(authenticator=provider, claim='ou', attribute='ou__slug')
205 207
    return provider
206 208

  
207 209

  
......
672 674
def test_strategy_find_email(app, caplog, code, oidc_provider, oidc_provider_jwkset, simple_user):
673 675
    OIDCClaimMapping.objects.all().delete()
674 676
    OIDCClaimMapping.objects.create(
675
        provider=oidc_provider,
677
        authenticator=oidc_provider,
676 678
        claim='email',
677 679
        attribute='email',
678 680
        idtoken_claim=False,  # served by user_info endpoint
......
729 731
):
730 732
    OIDCClaimMapping.objects.all().delete()
731 733
    OIDCClaimMapping.objects.create(
732
        provider=oidc_provider,
734
        authenticator=oidc_provider,
733 735
        claim='email',
734 736
        attribute='email',
735 737
        idtoken_claim=False,  # served by user_info endpoint
......
908 910
    OIDCClaimMapping.objects.all().delete()
909 911

  
910 912
    OIDCClaimMapping.objects.create(
911
        provider=oidc_provider,
913
        authenticator=oidc_provider,
912 914
        attribute='username',
913 915
        idtoken_claim=False,
914 916
        claim='{{ given_name }} "{{ nickname }}" {{ family_name }}',
915 917
    )
916 918
    OIDCClaimMapping.objects.create(
917
        provider=oidc_provider,
919
        authenticator=oidc_provider,
918 920
        attribute='pro_phone',
919 921
        idtoken_claim=False,
920 922
        claim='(prefix +33) {{ phone_number }}',
921 923
    )
922 924
    OIDCClaimMapping.objects.create(
923
        provider=oidc_provider,
925
        authenticator=oidc_provider,
924 926
        attribute='email',
925 927
        idtoken_claim=False,
926 928
        claim='{{ given_name }}@foo.bar',
927 929
    )
928 930
    # last one, with an idtoken claim
929 931
    OIDCClaimMapping.objects.create(
930
        provider=oidc_provider,
932
        authenticator=oidc_provider,
931 933
        attribute='last_name',
932 934
        idtoken_claim=True,
933 935
        claim='{{ name|upper }}',
934 936
    )
935 937
    # typo in template string
936 938
    OIDCClaimMapping.objects.create(
937
        provider=oidc_provider,
939
        authenticator=oidc_provider,
938 940
        attribute='first_name',
939 941
        idtoken_claim=True,
940 942
        claim='{{ given_name',
tests/test_manager_authenticators.py
198 198
    authenticator = OIDCProvider.objects.create(slug='idp1')
199 199
    resp = login(app, superuser, path=authenticator.get_absolute_url())
200 200

  
201
    resp = resp.click('Add')
201
    resp = resp.click('Add', href='claim')
202 202
    resp.form['claim'] = 'email'
203 203
    resp.form['attribute'].select(text='Email address (email)')
204 204
    resp.form['verified'].select(text='verified claim')
205 205
    resp.form['required'] = True
206 206
    resp.form['idtoken_claim'] = True
207 207
    resp = resp.form.submit()
208
    assert_event('authenticator.oidc.claim.creation', user=superuser, session=app.session)
209
    assert '#open:claims' in resp.location
208
    assert_event('authenticator.related_object.creation', user=superuser, session=app.session)
209
    assert '#open:oidcclaimmapping' in resp.location
210 210

  
211 211
    resp = resp.follow()
212 212
    assert 'email → Email address (email), verified, required, idtoken' in resp.text
......
215 215
    resp.form['attribute'].select(text='First name (first_name)')
216 216
    resp = resp.form.submit().follow()
217 217
    assert 'email → First name (first_name), verified, required, idtoken' in resp.text
218
    assert_event('authenticator.oidc.claim.edit', user=superuser, session=app.session)
218
    assert_event('authenticator.related_object.edit', user=superuser, session=app.session)
219 219

  
220 220
    resp = resp.click('Remove')
221 221
    resp = resp.form.submit().follow()
222 222
    assert 'email' not in resp.text
223
    assert_event('authenticator.oidc.claim.deletion', user=superuser, session=app.session)
223
    assert_event('authenticator.related_object.deletion', user=superuser, session=app.session)
224 224

  
225 225

  
226 226
def test_authenticators_fc(app, superuser):
tests/test_manager_journal.py
28 28
from authentic2.custom_user.models import Profile, ProfileType, User
29 29
from authentic2.journal import journal
30 30
from authentic2.models import Service
31
from authentic2_auth_oidc.models import OIDCClaimMapping, OIDCProvider
32 31
from authentic2_auth_saml.models import SAMLAuthenticator, SetAttributeAction
33 32

  
34 33
from .utils import login, logout, text_content
......
62 61
    authenticator = LoginPasswordAuthenticator.objects.create(slug='test')
63 62
    saml_authenticator = SAMLAuthenticator.objects.create(slug='saml')
64 63
    set_attribute_action = SetAttributeAction.objects.create(authenticator=saml_authenticator)
65
    oidc_provider = OIDCProvider.objects.create(slug='oidc')
66
    oidc_claim_mapping = OIDCClaimMapping.objects.create(provider=oidc_provider)
67 64

  
68 65
    class EventFactory:
69 66
        date = make_aware(datetime.datetime(2020, 1, 1))
......
325 322
        session=session2,
326 323
        related_object=set_attribute_action,
327 324
    )
328
    make('authenticator.oidc.claim.creation', user=agent, session=session2, claim=oidc_claim_mapping)
329
    claim_edit_form = mock.Mock(spec=['instance', 'initial', 'changed_data', 'cleaned_data'])
330
    claim_edit_form.instance = oidc_claim_mapping
331
    claim_edit_form.initial = {'claim': 'email'}
332
    claim_edit_form.changed_data = ['claim']
333
    claim_edit_form.cleaned_data = {'claim': 'first_name'}
334
    make('authenticator.oidc.claim.edit', user=agent, session=session2, form=claim_edit_form)
335
    make('authenticator.oidc.claim.deletion', user=agent, session=session2, claim=oidc_claim_mapping)
336 325

  
337 326
    # verify we created at least one event for each type
338 327
    assert set(Event.objects.values_list("type__name", flat=True)) == set(_registry)
......
370 359
def test_global_journal(app, superuser, events):
371 360
    response = login(app, user=superuser, path="/manage/")
372 361
    set_attribute_action = SetAttributeAction.objects.get()
373
    oidc_claim_mapping = OIDCClaimMapping.objects.get()
374 362

  
375 363
    # remove event about admin login
376 364
    Event.objects.filter(user=superuser).delete()
......
747 735
            'type': 'authenticator.related_object.deletion',
748 736
            'user': 'agent',
749 737
        },
750
        {
751
            'message': 'creation of claim (%s) in provider "OpenIDConnect"' % oidc_claim_mapping.pk,
752
            'timestamp': 'Jan. 3, 2020, 11 a.m.',
753
            'type': 'authenticator.oidc.claim.creation',
754
            'user': 'agent',
755
        },
756
        {
757
            'message': 'edit claim (%s) in provider "OpenIDConnect" (claim)' % oidc_claim_mapping.pk,
758
            'timestamp': 'Jan. 3, 2020, noon',
759
            'type': 'authenticator.oidc.claim.edit',
760
            'user': 'agent',
761
        },
762
        {
763
            'message': 'deletion of claim (%s) in provider "OpenIDConnect"' % oidc_claim_mapping.pk,
764
            'timestamp': 'Jan. 3, 2020, 1 p.m.',
765
            'type': 'authenticator.oidc.claim.deletion',
766
            'user': 'agent',
767
        },
768 738
    ]
769 739

  
770 740
    agent_page = response.click('agent', index=1)
771
-