Projet

Général

Profil

0001-start-apiaccess-infrastructure-63523.patch

Emmanuel Cazenave, 27 juin 2022 17:09

Télécharger (22,6 ko)

Voir les différences:

Subject: [PATCH] start apiaccess infrastructure (#63523)

 debian/debian_config_common.py                |   5 +-
 debian/server/uwsgi.ini                       |   3 +
 .../agent/common/migrations/0005_apiaccess.py |  26 +++
 hobo/agent/common/models.py                   |   8 +
 .../management/commands/add_apiaccess.py      |  43 ++++
 .../commands/provision_apiaccess.py           |  29 +++
 hobo/apiaccess/provisionning.py               |  58 +++++
 hobo/provisionning/utils.py                   |  31 ++-
 hobo/rest_authentication.py                   |  21 ++
 hobo/settings.py                              |   1 +
 tests/test_apiaccess.py                       | 218 ++++++++++++++++++
 tox.ini                                       |   1 +
 12 files changed, 442 insertions(+), 2 deletions(-)
 create mode 100644 hobo/agent/common/migrations/0005_apiaccess.py
 create mode 100644 hobo/apiaccess/management/commands/add_apiaccess.py
 create mode 100644 hobo/apiaccess/management/commands/provision_apiaccess.py
 create mode 100644 hobo/apiaccess/provisionning.py
 create mode 100644 tests/test_apiaccess.py
debian/debian_config_common.py
398 398
if 'rest_framework' in INSTALLED_APPS:
399 399
    if 'REST_FRAMEWORK' not in globals():
400 400
        REST_FRAMEWORK = {}
401
    REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] = ('hobo.rest_authentication.PublikAuthentication',)
401
    REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] = (
402
        'hobo.rest_authentication.PublikAuthentication',
403
        'hobo.rest_authentication.APIAccessAuthentication',
404
    )
402 405
    REST_FRAMEWORK['DEFAULT_PERMISSION_CLASSES'] = ('rest_framework.permissions.IsAuthenticated',)
403 406
    REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = ('rest_framework.renderers.JSONRenderer',)
404 407

  
debian/server/uwsgi.ini
16 16
spooler-python-import = hobo.provisionning.spooler
17 17
spooler-max-tasks = 20
18 18

  
19
# hourly
20
unique-cron = 1 -1 -1 -1 -1 /usr/bin/hobo-manage tenant_command provision_apiaccess --all-tenants
21

  
19 22
master = true
20 23
enable-threads = true
21 24
harakiri = 120
hobo/agent/common/migrations/0005_apiaccess.py
1
# Generated by Django 2.2.26 on 2022-06-01 10:19
2

  
3
from django.db import migrations, models
4

  
5

  
6
class Migration(migrations.Migration):
7

  
8
    dependencies = [
9
        ('common', '0004_alter_role_uuid'),
10
    ]
11

  
12
    operations = [
13
        migrations.CreateModel(
14
            name='APIAccess',
15
            fields=[
16
                (
17
                    'id',
18
                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
19
                ),
20
                ('name', models.CharField(max_length=128, unique=True, verbose_name='name')),
21
                ('identifier', models.CharField(max_length=128, unique=True, verbose_name='identifier')),
22
                ('key', models.CharField(max_length=128, verbose_name='key')),
23
                ('description', models.CharField(blank=True, max_length=128, verbose_name='description')),
24
            ],
25
        ),
26
    ]
hobo/agent/common/models.py
1 1
from django.contrib.auth.models import Group
2 2
from django.contrib.postgres.fields import ArrayField
3 3
from django.db import models
4
from django.utils.translation import ugettext_lazy as _
4 5

  
5 6

  
6 7
class Role(Group):
......
11 12
    emails_to_members = models.BooleanField(default=True)
12 13

  
13 14
    objects = models.Manager()
15

  
16

  
17
class APIAccess(models.Model):
18
    name = models.CharField(max_length=128, unique=True, verbose_name=_('name'))
19
    identifier = models.CharField(max_length=128, unique=True, verbose_name=_('identifier'))
20
    key = models.CharField(max_length=128, verbose_name=_('key'))
21
    description = models.CharField(max_length=128, blank=True, verbose_name=_('description'))
hobo/apiaccess/management/commands/add_apiaccess.py
1
# hobo - portal to configure and deploy applications
2
# Copyright (C) 2015-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
import uuid
18

  
19
from django.core.management.base import BaseCommand, CommandError
20

  
21
from hobo.agent.common.models import APIAccess
22
from hobo.apiaccess.provisionning import notify_agents
23

  
24

  
25
class Command(BaseCommand):
26
    help = 'Add an API Access entry'
27

  
28
    def add_arguments(self, parser):
29
        parser.add_argument('name', type=str)
30

  
31
    def handle(self, *args, **options):
32
        name = options['name']
33
        try:
34
            apiaccess = APIAccess.objects.get(name=name)
35
            self.stdout.write(self.style.WARNING('An API Access entry with this name already exists.'))
36
        except APIAccess.DoesNotExist:
37
            apiaccess = APIAccess.objects.create(
38
                identifier=str(uuid.uuid4()), name=name, key=str(uuid.uuid4())
39
            )
40
            notify_agents([apiaccess])
41
            self.stdout.write(self.style.SUCCESS('API Access created.'))
42
            self.stdout.write(self.style.SUCCESS('Identifier: %s' % apiaccess.identifier))
43
            self.stdout.write(self.style.SUCCESS('Key: %s' % apiaccess.key))
hobo/apiaccess/management/commands/provision_apiaccess.py
1
# hobo - portal to configure and deploy applications
2
# Copyright (C) 2015-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
import uuid
18

  
19
from django.core.management.base import BaseCommand, CommandError
20

  
21
from hobo.agent.common.models import APIAccess
22
from hobo.apiaccess.provisionning import notify_agents
23

  
24

  
25
class Command(BaseCommand):
26
    help = 'Provision api access'
27

  
28
    def handle(self, *args, **options):
29
        notify_agents((apiaccess for apiaccess in APIAccess.objects.all()), full=True)
hobo/apiaccess/provisionning.py
1
# hobo - portal to configure and deploy applications
2
# Copyright (C) 2015-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
import requests
18
from django.conf import settings
19

  
20
from hobo.environment.utils import get_installed_services
21
from hobo.signature import sign_url
22

  
23

  
24
def notify_agents(apiaccesses, mode='provision', full=False, sync=False):
25
    data = {
26
        '@type': mode,
27
        'full': full,
28
        'objects': {
29
            '@type': 'apiaccess',
30
            'data': [
31
                {
32
                    'identifier': apiaccess.identifier,
33
                    'key': apiaccess.key,
34
                    'name': apiaccess.name,
35
                    'description': apiaccess.description,
36
                }
37
                for apiaccess in apiaccesses
38
            ],
39
        },
40
    }
41
    known_services = getattr(settings, 'KNOWN_SERVICES', {})
42
    for service_id, services in known_services.items():
43
        if service_id in ('hobo', 'wcs'):
44
            continue
45
        for service in services.values():
46
            if service['secondary']:
47
                continue
48
            provisionning_url = service.get('provisionning-url')
49
            if not provisionning_url:
50
                continue
51
            url = service['provisionning-url'] + '?orig=%s' % service['orig']
52
            if sync:
53
                url += '&sync=1'
54
            try:
55
                response = requests.put(sign_url(url, service['secret']), json=data)
56
                response.raise_for_status()
57
            except requests.RequestException as e:
58
                pass
hobo/provisionning/utils.py
22 22
from django.db.models.query import Q
23 23
from django.db.transaction import atomic
24 24

  
25
from hobo.agent.common.models import Role
25
from hobo.agent.common.models import APIAccess, Role
26 26
from hobo.multitenant.utils import provision_user_groups
27 27

  
28 28
logger = logging.getLogger(__name__)
......
250 250
            )
251 251
            qs.delete()
252 252

  
253
    @classmethod
254
    def provision_apiaccess(cls, issuer, action, data, full=False):
255
        if action == 'provision':
256
            identifiers = []
257
            for record in data:
258
                try:
259
                    apiaccess = APIAccess.objects.get(identifier=record['identifier'])
260
                    apiaccess.name = record['name']
261
                    apiaccess.key = record['key']
262
                    apiaccess.description = record['description']
263
                    apiaccess.save()
264
                except APIAccess.DoesNotExist:
265
                    APIAccess.objects.create(
266
                        identifier=record['identifier'],
267
                        key=record['key'],
268
                        name=record['name'],
269
                        description=record['description'],
270
                    )
271
                identifiers.append(record['identifier'])
272
            if full:
273
                APIAccess.objects.exclude(identifier__in=identifiers).delete()
274

  
275
        if action == 'deprovision':
276
            for record in data:
277
                try:
278
                    apiacess = APIAccess.objects.get(key=record['key']).delete()
279
                except APIAccess.DoesNotExist:
280
                    pass
281

  
253 282
    @classmethod
254 283
    def provision(cls, object_type, issuer, action, data, full):
255 284
        for i in range(20):
hobo/rest_authentication.py
8 8
from rest_framework import authentication, exceptions, status
9 9

  
10 10
from hobo import signature
11
from hobo.agent.common.models import APIAccess
11 12

  
12 13
try:
13 14
    from mellon.models import UserSAMLIdentifier
......
125 126
        user = self.resolve_user(request)
126 127
        self.logger.info('user authenticated with signature %s', user)
127 128
        return (user, None)
129

  
130

  
131
class APIAccessAuthenticationFailed(exceptions.APIException):
132
    status_code = status.HTTP_401_UNAUTHORIZED
133

  
134
    def __init__(self, err_desc):
135
        self.detail = {'err': 1, 'err_desc': err_desc}
136

  
137

  
138
class APIAccessAuthentication(authentication.BasicAuthentication):
139
    def authenticate_credentials(self, identifier, key, request=None):
140
        try:
141
            APIAccess.objects.get(identifier=identifier, key=key)
142
        except APIAccess.DoesNotExist:
143
            raise APIAccessAuthenticationFailed('apiaccess-not-found')
144

  
145
        if hasattr(settings, 'HOBO_ANONYMOUS_SERVICE_USER_CLASS'):
146
            klass = import_string(settings.HOBO_ANONYMOUS_SERVICE_USER_CLASS)
147
            return (klass(), None)
148
        raise APIAccessAuthenticationFailed('apiaccess-configuration-error')
hobo/settings.py
42 42
    'rest_framework',
43 43
    'mellon',
44 44
    'gadjo',
45
    'hobo.apiaccess',
45 46
    'hobo.applications',
46 47
    'hobo.debug',
47 48
    'hobo.environment',
tests/test_apiaccess.py
1
import base64
2
from io import StringIO
3

  
4
import mock
5
import pytest
6
import responses
7
from django.core.management import call_command
8
from django.test import RequestFactory
9

  
10
import hobo.apiaccess.management.commands.add_apiaccess
11
from hobo.agent.common.models import APIAccess
12
from hobo.apiaccess.provisionning import notify_agents
13
from hobo.rest_authentication import APIAccessAuthentication, APIAccessAuthenticationFailed
14
from hobo.signature import sign_url
15

  
16

  
17
def test_add_apiaccess_command(db, monkeypatch):
18
    assert APIAccess.objects.count() == 0
19
    notify_agents = mock.Mock()
20
    monkeypatch.setattr(hobo.apiaccess.management.commands.add_apiaccess, 'notify_agents', notify_agents)
21
    out = StringIO()
22
    call_command('add_apiaccess', 'foo', stdout=out)
23
    assert APIAccess.objects.count() == 1
24
    apiaccess = APIAccess.objects.get(name='foo')
25
    notify_agents.assert_called_once_with([apiaccess])
26
    stdout = out.getvalue()
27
    assert 'API Access created.' in stdout
28
    assert 'Identifier: %s' % apiaccess.identifier in stdout
29
    assert 'Key: %s' % apiaccess.key in stdout
30

  
31
    notify_agents.reset_mock()
32
    out = StringIO()
33
    call_command('add_apiaccess', 'foo', stdout=out)
34
    assert APIAccess.objects.count() == 1
35
    notify_agents.assert_not_called()
36
    assert 'An API Access entry with this name already exists.' in out.getvalue()
37

  
38

  
39
def test_apiaccess_authentication(db, settings):
40
    settings.HOBO_ANONYMOUS_SERVICE_USER_CLASS = 'hobo.rest_authentication.AnonymousAdminServiceUser'
41
    URL = '/api/'
42
    auth_headers = {
43
        'HTTP_AUTHORIZATION': 'Basic ' + base64.b64encode('foo:bar'.encode('utf-8')).decode('utf-8'),
44
    }
45
    factory = RequestFactory()
46
    request = factory.get('/api/', **auth_headers)
47
    apiaccess_authentication = APIAccessAuthentication()
48

  
49
    with pytest.raises(APIAccessAuthenticationFailed) as excinfo:
50
        apiaccess_authentication.authenticate(request)
51
    assert 'apiaccess-not-found' in str(excinfo.value)
52

  
53
    apiaccess = APIAccess.objects.create(identifier='foo', key='bar')
54
    result = apiaccess_authentication.authenticate(request)
55
    assert isinstance(result, tuple)
56
    assert len(result) == 2
57
    assert result[1] is None
58
    user = result[0]
59
    assert user.is_staff
60
    assert user.is_anonymous
61
    assert user.is_authenticated
62

  
63

  
64
def test_apiaccess_notify_agents(db, settings):
65
    settings.KNOWN_SERVICES = {
66
        'chrono': {
67
            'foo': {
68
                'title': 'Foo',
69
                'url': 'https://chrono.example.invalid/',
70
                'orig': 'example.org',
71
                'secret': 'xxx',
72
                'provisionning-url': 'https://chrono.example.invalid/__provision__/',
73
                'secondary': False,
74
            }
75
        },
76
        'combo': {
77
            'bar': {
78
                'title': 'Bar',
79
                'url': 'https://combo.example.invalid/',
80
                'orig': 'example.org',
81
                'secret': 'xxx',
82
                'provisionning-url': 'https://combo.example.invalid/__provision__/',
83
                'secondary': False,
84
            }
85
        },
86
    }
87
    with responses.RequestsMock() as rsps:
88
        rsps.put(
89
            'https://chrono.example.invalid/__provision__/',
90
            status=200,
91
            match=[responses.matchers.query_param_matcher({}, strict_match=False)],
92
        )
93
        rsps.put(
94
            'https://combo.example.invalid/__provision__/',
95
            status=200,
96
            match=[responses.matchers.query_param_matcher({}, strict_match=False)],
97
        )
98
        apiaccess = APIAccess.objects.create(identifier='foo', key='bar', name='Foo')
99
        notify_agents([apiaccess])
100
        assert len(rsps.calls) == 2
101

  
102

  
103
def test_apiaccess_provision(db, app, settings):
104
    settings.HOBO_ANONYMOUS_SERVICE_USER_CLASS = 'hobo.rest_authentication.AnonymousAdminServiceUser'
105
    settings.KNOWN_SERVICES = {
106
        'chrono': {
107
            'foo': {
108
                'title': 'Foo',
109
                'url': 'https://chrono.example.invalid/',
110
                'verif_orig': 'chrono.example.invalid',
111
                'secret': 'xxx',
112
                'provisionning-url': 'https://chrono.example.invalid/__provision__/',
113
                'secondary': False,
114
            }
115
        },
116
        'authentic': {
117
            'bar': {
118
                'title': 'Bar',
119
                'url': 'https://authentic.example.invalid/',
120
                'verif_orig': 'authentic.example.invalid',
121
                'secret': 'xxx',
122
                'provisionning-url': 'https://authentic.example.invalid/__provision__/',
123
                'secondary': False,
124
            }
125
        },
126
        'hobo': {
127
            'hobo': {
128
                'title': 'Hobo',
129
                'url': 'https://hobo.example.invalid/',
130
                'verif_orig': 'hobo.example.invalid',
131
                'secret': 'xxx',
132
                'provisionning-url': 'https://hobo.example.invalid/__provision__/',
133
                'secondary': False,
134
            }
135
        },
136
    }
137

  
138
    # create one api access
139
    notification = {
140
        '@type': 'provision',
141
        'objects': {
142
            '@type': 'apiaccess',
143
            'data': [{'key': 'key', 'identifier': 'id', 'name': 'name', 'description': 'description'}],
144
        },
145
    }
146
    assert APIAccess.objects.count() == 0
147
    resp = app.put_json(sign_url('/__provision__/?orig=%s' % 'hobo.example.invalid', 'xxx'), notification)
148
    assert APIAccess.objects.count() == 1
149
    apiaccess = APIAccess.objects.get(identifier='id')
150
    assert apiaccess.key == 'key'
151
    assert apiaccess.name == 'name'
152
    assert apiaccess.description == 'description'
153

  
154
    # update the api access
155
    notification = {
156
        '@type': 'provision',
157
        'objects': {
158
            '@type': 'apiaccess',
159
            'data': [
160
                {'key': 'new-key', 'identifier': 'id', 'name': 'new-name', 'description': 'new-description'}
161
            ],
162
        },
163
    }
164
    resp = app.put_json(sign_url('/__provision__/?orig=%s' % 'hobo.example.invalid', 'xxx'), notification)
165
    assert APIAccess.objects.count() == 1
166
    apiaccess = APIAccess.objects.get(identifier='id')
167
    assert apiaccess.key == 'new-key'
168
    assert apiaccess.name == 'new-name'
169
    assert apiaccess.description == 'new-description'
170

  
171
    # delete the api access
172
    notification = {
173
        '@type': 'deprovision',
174
        'objects': {
175
            '@type': 'apiaccess',
176
            'data': [
177
                {'key': 'new-key', 'identifier': 'id', 'name': 'new-name', 'description': 'new-description'}
178
            ],
179
        },
180
    }
181
    resp = app.put_json(sign_url('/__provision__/?orig=%s' % 'hobo.example.invalid', 'xxx'), notification)
182
    assert APIAccess.objects.count() == 0
183

  
184
    # create three api access
185
    notification = {
186
        '@type': 'provision',
187
        'objects': {
188
            '@type': 'apiaccess',
189
            'data': [
190
                {'key': 'key1', 'identifier': 'id1', 'name': 'name1', 'description': 'description1'},
191
                {'key': 'key2', 'identifier': 'id2', 'name': 'name2', 'description': 'description2'},
192
                {'key': 'key3', 'identifier': 'id3', 'name': 'name3', 'description': 'description3'},
193
            ],
194
        },
195
    }
196
    assert APIAccess.objects.count() == 0
197
    resp = app.put_json(sign_url('/__provision__/?orig=%s' % 'hobo.example.invalid', 'xxx'), notification)
198
    assert APIAccess.objects.count() == 3
199
    assert APIAccess.objects.get(identifier='id1')
200
    assert APIAccess.objects.get(identifier='id2')
201
    assert APIAccess.objects.get(identifier='id3')
202

  
203
    # full provisionning
204
    notification = {
205
        '@type': 'provision',
206
        'full': True,
207
        'objects': {
208
            '@type': 'apiaccess',
209
            'data': [
210
                {'key': 'key1', 'identifier': 'id1', 'name': 'name1', 'description': 'description1'},
211
                {'key': 'key2', 'identifier': 'id2', 'name': 'name2', 'description': 'description2'},
212
            ],
213
        },
214
    }
215
    resp = app.put_json(sign_url('/__provision__/?orig=%s' % 'hobo.example.invalid', 'xxx'), notification)
216
    assert APIAccess.objects.count() == 2
217
    assert APIAccess.objects.get(key='key1')
218
    assert APIAccess.objects.get(key='key2')
tox.ini
56 56
	enum34<=1.1.6
57 57
	psycopg2<2.9
58 58
	psycopg2-binary<2.9
59
        responses
59 60
	black: pre-commit
60 61
commands =
61 62
	./getlasso3.sh
62
-