0001-authentic-add-API-to-force-user-provisionning-53059.patch
hobo/agent/authentic2/apps.py | ||
---|---|---|
19 | 19 |
from django.conf import settings |
20 | 20 | |
21 | 21 | |
22 |
class Plugin: |
|
23 |
def get_before_urls(self): |
|
24 |
from . import urls |
|
25 |
return urls.urlpatterns |
|
26 | ||
27 | ||
22 | 28 |
class Authentic2AgentConfig(AppConfig): |
23 | 29 |
name = 'hobo.agent.authentic2' |
24 | 30 |
label = 'authentic2_agent' |
25 | 31 |
verbose_name = 'Authentic2 Agent' |
26 | 32 | |
33 |
def get_a2_plugin(self): |
|
34 |
return Plugin() |
|
35 | ||
27 | 36 |
def ready(self): |
28 | 37 |
from . import provisionning |
29 | 38 |
hobo/agent/authentic2/provisionning.py | ||
---|---|---|
424 | 424 | |
425 | 425 |
def notify_agents(self, data): |
426 | 426 |
if getattr(settings, 'HOBO_HTTP_PROVISIONNING', False): |
427 |
services_by_url = {} |
|
428 |
for services in settings.KNOWN_SERVICES.values(): |
|
429 |
for service in services.values(): |
|
430 |
if service.get('provisionning-url'): |
|
431 |
services_by_url[service['saml-sp-metadata-url']] = service |
|
432 |
audience = data.get('audience') |
|
433 |
rest_audience = [x for x in audience if x in services_by_url] |
|
434 |
amqp_audience = audience |
|
435 |
for audience in rest_audience: |
|
436 |
service = services_by_url[audience] |
|
437 |
data['audience'] = [audience] |
|
438 |
try: |
|
439 |
response = requests.put( |
|
440 |
sign_url(service['provisionning-url'] + '?orig=%s' % service['orig'], service['secret']), |
|
441 |
json=data) |
|
442 |
response.raise_for_status() |
|
443 |
except requests.RequestException as e: |
|
444 |
logger.error(u'error provisionning to %s (%s)', audience, e) |
|
445 |
else: |
|
446 |
amqp_audience.remove(audience) |
|
447 |
data['audience'] = amqp_audience |
|
448 |
if amqp_audience: |
|
449 |
logger.info(u'leftover AMQP audience: %s', amqp_audience) |
|
427 |
leftover_audience = self.notify_agents_http(data) |
|
428 |
if not leftover_audience: |
|
429 |
return |
|
430 |
logger.info('leftover AMQP audience: %s', leftover_audience) |
|
431 |
data['audience'] = leftover_audience |
|
450 | 432 | |
451 | 433 |
if data['audience']: |
452 | 434 |
notify_agents(data) |
435 | ||
436 |
def get_http_services_by_url(self): |
|
437 |
services_by_url = {} |
|
438 |
for services in settings.KNOWN_SERVICES.values(): |
|
439 |
for service in services.values(): |
|
440 |
if service.get('provisionning-url'): |
|
441 |
services_by_url[service['saml-sp-metadata-url']] = service |
|
442 |
return services_by_url |
|
443 | ||
444 |
def notify_agents_http(self, data): |
|
445 |
services_by_url = self.get_http_services_by_url() |
|
446 |
audience = data.get('audience') |
|
447 |
rest_audience = [x for x in audience if x in services_by_url] |
|
448 |
leftover_audience = audience |
|
449 |
for audience in rest_audience: |
|
450 |
service = services_by_url[audience] |
|
451 |
data['audience'] = [audience] |
|
452 |
try: |
|
453 |
response = requests.put( |
|
454 |
sign_url(service['provisionning-url'] + '?orig=%s' % service['orig'], service['secret']), |
|
455 |
json=data) |
|
456 |
response.raise_for_status() |
|
457 |
except requests.RequestException as e: |
|
458 |
logger.error(u'error provisionning to %s (%s)', audience, e) |
|
459 |
else: |
|
460 |
leftover_audience.remove(audience) |
|
461 |
return leftover_audience |
hobo/agent/authentic2/urls.py | ||
---|---|---|
1 |
# hobo - portal to configure and deploy applications |
|
2 |
# Copyright (C) 2015-2021 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.conf.urls import url |
|
18 | ||
19 |
from . import views |
|
20 | ||
21 |
urlpatterns = [ |
|
22 |
url(r'^api/provision/$', views.provision_view), |
|
23 |
] |
hobo/agent/authentic2/views.py | ||
---|---|---|
1 |
# hobo - portal to configure and deploy applications |
|
2 |
# Copyright (C) 2015-2021 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.conf import settings |
|
18 | ||
19 |
from rest_framework import permissions, serializers, status |
|
20 |
from rest_framework.response import Response |
|
21 |
from rest_framework.generics import GenericAPIView |
|
22 | ||
23 |
from . import provisionning |
|
24 | ||
25 | ||
26 |
class ProvisionSerializer(serializers.Serializer): |
|
27 |
user_uuid = serializers.CharField(required=False) |
|
28 |
role_uuid = serializers.CharField(required=False) |
|
29 |
service_type = serializers.CharField(required=False) |
|
30 |
service_url = serializers.CharField(required=False) |
|
31 | ||
32 |
def validate(self, data): |
|
33 |
if not (data.get('user_uuid') or data.get('role_uuid')): |
|
34 |
raise serializers.ValidationError('must provide user_uuid or role_uuid') |
|
35 |
if data.get('user_uuid') and data.get('role_uuid'): |
|
36 |
raise serializers.ValidationError('cannot provision both user & role') |
|
37 |
return data |
|
38 | ||
39 | ||
40 |
class ProvisionView(GenericAPIView): |
|
41 |
permission_classes = (permissions.IsAuthenticated,) |
|
42 |
serializer_class = ProvisionSerializer |
|
43 | ||
44 |
def post(self, request): |
|
45 |
serializer = self.get_serializer(data=request.data) |
|
46 |
if not serializer.is_valid(): |
|
47 |
return Response({'err': 1, 'errors': serializer.errors}, status.HTTP_400_BAD_REQUEST) |
|
48 | ||
49 |
engine = ApiProvisionningEngine( |
|
50 |
service_type=serializer.validated_data.get('service_type'), |
|
51 |
service_url=serializer.validated_data.get('service_url'), |
|
52 |
) |
|
53 | ||
54 |
user_uuid = serializer.validated_data.get('user_uuid') |
|
55 |
role_uuid = serializer.validated_data.get('role_uuid') |
|
56 |
if user_uuid: |
|
57 |
try: |
|
58 |
user = provisionning.User.objects.get(uuid=user_uuid) |
|
59 |
except provisionning.User.DoesNotExist: |
|
60 |
return Response({'err': 1, 'err_desc': 'unknown user UUID'}) |
|
61 |
engine.notify_users(ous=None, users=[user]) |
|
62 |
elif role_uuid: |
|
63 |
try: |
|
64 |
role = provisionning.Role.objects.get(uuid=role_uuid) |
|
65 |
except provisionning.Role.DoesNotExist: |
|
66 |
return Response({'err': 1, 'err_desc': 'unknown role UUID'}) |
|
67 |
ous = {ou.id: ou for ou in provisionning.OU.objects.all()} |
|
68 |
engine.notify_roles(ous=ous, roles=[role]) |
|
69 | ||
70 |
response = {'err': 0} |
|
71 |
response['leftover_audience'] = engine.leftover_audience |
|
72 |
return Response(response) |
|
73 | ||
74 | ||
75 |
provision_view = ProvisionView.as_view() |
|
76 | ||
77 | ||
78 |
class ApiProvisionningEngine(provisionning.Provisionning): |
|
79 |
def __init__(self, service_type=None, service_url=None): |
|
80 |
super().__init__() |
|
81 |
self.service_type = service_type |
|
82 |
self.service_url = service_url |
|
83 | ||
84 |
def get_http_services_by_url(self): |
|
85 |
if self.service_type: |
|
86 |
services_by_url = {} |
|
87 |
for service in settings.KNOWN_SERVICES[self.service_type].values(): |
|
88 |
if service.get('provisionning-url'): |
|
89 |
services_by_url[service['saml-sp-metadata-url']] = service |
|
90 |
else: |
|
91 |
services_by_url = super().get_http_services_by_url() |
|
92 |
if self.service_url: |
|
93 |
services_by_url = {k: v for k, v in services_by_url.items() if self.service_url in v['url']} |
|
94 |
return services_by_url |
|
95 | ||
96 |
def notify_agents(self, data): |
|
97 |
self.leftover_audience = self.notify_agents_http(data) |
|
98 |
# only include filtered services in leftovers |
|
99 |
services_by_url = self.get_http_services_by_url() |
|
100 |
self.leftover_audience = [x for x in self.leftover_audience if x in services_by_url] |
tests_authentic/conftest.py | ||
---|---|---|
54 | 54 |
'title': 'Other', |
55 | 55 |
'service-id': 'welco', |
56 | 56 |
'secret_key': 'abcdef', |
57 |
'base_url': 'http://other.example.net' |
|
57 |
'url': 'http://other.example.net', |
|
58 |
'base_url': 'http://other.example.net', |
|
59 |
'provisionning-url': 'http://other.example.net/__provision__/', |
|
60 |
'saml-sp-metadata-url': 'http://other.example.net/metadata/', |
|
58 | 61 |
}, |
62 |
{ |
|
63 |
'slug': 'more', |
|
64 |
'title': 'More', |
|
65 |
'service-id': 'wcs', |
|
66 |
'secret_key': 'abcdef', |
|
67 |
'url': 'http://more.example.net', |
|
68 |
'base_url': 'http://more.example.net', |
|
69 |
'provisionning-url': 'http://more.example.net/__provision__/', |
|
70 |
'saml-sp-metadata-url': 'http://more.example.net/metadata/', |
|
71 |
}, |
|
72 | ||
59 | 73 |
] |
60 | 74 |
}, fd) |
61 | 75 |
schema_name = name.replace('-', '_').replace('.', '_') |
tests_authentic/test_provisionning.py | ||
---|---|---|
1 | 1 |
# -*- coding: utf-8 -*- |
2 | ||
2 | 3 |
import json |
3 | 4 | |
4 | 5 |
import pytest |
... | ... | |
16 | 17 |
from authentic2.a2_rbac.utils import get_default_ou |
17 | 18 |
from authentic2.models import Attribute, AttributeValue |
18 | 19 |
from django_rbac.utils import get_ou_model |
20 | ||
19 | 21 |
from hobo.agent.authentic2.provisionning import provisionning |
22 |
from hobo import signature |
|
20 | 23 | |
21 | 24 |
User = get_user_model() |
22 | 25 | |
... | ... | |
594 | 597 |
username='coin2', email='coin2@coin.org', interactive=False) |
595 | 598 |
assert notify_agents.call_count == 0 |
596 | 599 |
assert requests_put.call_count == 2 |
600 | ||
601 | ||
602 |
def test_provisionning_api(transactional_db, app_factory, tenant, settings, caplog): |
|
603 |
with tenant_context(tenant): |
|
604 |
# create providers so notification messages have an audience. |
|
605 |
LibertyProvider.objects.create(ou=get_default_ou(), name='provider', slug='provider', |
|
606 |
entity_id='http://other.example.net/metadata/', |
|
607 |
protocol_conformance=lasso.PROTOCOL_SAML_2_0) |
|
608 |
LibertyProvider.objects.create(ou=get_default_ou(), name='provider2', slug='provider2', |
|
609 |
entity_id='http://more.example.net/metadata/', |
|
610 |
protocol_conformance=lasso.PROTOCOL_SAML_2_0) |
|
611 | ||
612 |
role = Role.objects.create(name='coin', ou=get_default_ou()) |
|
613 |
user = User.objects.create(username='Étienne', |
|
614 |
email='etienne.dugenou@example.net', |
|
615 |
first_name='Étienne', |
|
616 |
last_name='Dugenou', |
|
617 |
ou=get_default_ou()) |
|
618 | ||
619 |
app = app_factory(tenant) |
|
620 |
resp = app.post_json('/api/provision/', {}, status=403) |
|
621 | ||
622 |
orig = settings.KNOWN_SERVICES['welco']['other']['verif_orig'] |
|
623 |
key = settings.KNOWN_SERVICES['welco']['other']['secret'] |
|
624 |
resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig, key), {}, status=400) |
|
625 |
assert resp.json['errors']['__all__'] == ['must provide user_uuid or role_uuid'] |
|
626 | ||
627 |
resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig, |
|
628 |
key), {'user_uuid': 'xxx', 'role_uuid': 'yyy'}, status=400) |
|
629 |
assert resp.json['errors']['__all__'] == ['cannot provision both user & role'] |
|
630 | ||
631 |
resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig, |
|
632 |
key), {'user_uuid': 'xxx'}, status=200) |
|
633 |
assert resp.json == {'err': 1, 'err_desc': 'unknown user UUID'} |
|
634 | ||
635 |
resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig, |
|
636 |
key), {'role_uuid': 'xxx'}, status=200) |
|
637 |
assert resp.json == {'err': 1, 'err_desc': 'unknown role UUID'} |
|
638 | ||
639 |
with patch('hobo.agent.authentic2.provisionning.requests.put') as requests_put: |
|
640 |
resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig, |
|
641 |
key), {'user_uuid': user.uuid}) |
|
642 |
assert requests_put.call_count == 2 |
|
643 | ||
644 |
with patch('hobo.agent.authentic2.provisionning.requests.put') as requests_put: |
|
645 |
resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig, |
|
646 |
key), {'user_uuid': user.uuid, 'service_type': 'welco'}) |
|
647 |
assert requests_put.call_count == 1 |
|
648 | ||
649 |
with patch('hobo.agent.authentic2.provisionning.requests.put') as requests_put: |
|
650 |
resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig, |
|
651 |
key), {'user_uuid': user.uuid, 'service_url': 'example.net'}) |
|
652 |
assert requests_put.call_count == 2 |
|
653 | ||
654 |
with patch('hobo.agent.authentic2.provisionning.requests.put') as requests_put: |
|
655 |
resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig, |
|
656 |
key), {'role_uuid': role.uuid}) |
|
657 |
assert requests_put.call_count == 2 |
|
597 |
- |