From 0ad5f55bddcf9d4dbf4198254b622eafacf3e2ae Mon Sep 17 00:00:00 2001
From: Paul Marillonnet <pmarillonnet@entrouvert.com>
Date: Sat, 1 Aug 2020 09:42:29 +0200
Subject: [PATCH] profile_views: add a profile page to manage oidc-authorized
 ous (#45651)

---
 src/authentic2/app_settings.py                |  3 +
 .../templates/authentic2/accounts.html        |  3 +
 .../accounts_authorized_oauth_ous.html        | 67 +++++++++++++++++++
 src/authentic2/urls.py                        |  5 +-
 src/authentic2/views.py                       | 45 +++++++++++--
 tests/test_idp_oidc.py                        | 49 ++++++++++++++
 tests/test_profile.py                         | 11 +++
 7 files changed, 177 insertions(+), 6 deletions(-)
 create mode 100644 src/authentic2/templates/authentic2/accounts_authorized_oauth_ous.html

diff --git a/src/authentic2/app_settings.py b/src/authentic2/app_settings.py
index 8881872e0..f19418a8d 100644
--- a/src/authentic2/app_settings.py
+++ b/src/authentic2/app_settings.py
@@ -130,6 +130,9 @@ default_settings = dict(
     A2_PROFILE_DISPLAY_SERVICE_AUTHORIZATIONS_PAGE=Setting(
         default=True,
         definition='Display page for user to revoke granted services access to its account profile data'),
+    A2_PROFILE_DISPLAY_OU_AUTHORIZATIONS_PAGE=Setting(
+        default=True,
+        definition='Display page for user to revoke granted services within OUs access to its account profile data'),
     A2_PROFILE_DISPLAY_EMPTY_FIELDS=Setting(
         default=False,
         definition='Include empty fields in profile view'),
diff --git a/src/authentic2/templates/authentic2/accounts.html b/src/authentic2/templates/authentic2/accounts.html
index d5cc82f69..33221b119 100644
--- a/src/authentic2/templates/authentic2/accounts.html
+++ b/src/authentic2/templates/authentic2/accounts.html
@@ -40,6 +40,9 @@
       {% if allow_authorization_management %}
         <p><a href="{% url 'authorized-oauth-services' %}">{% trans "Manage service authorizations" %}</a></p>
       {% endif %}
+      {% if allow_authorization_management_by_ou %}
+        <p><a href="{% url 'authorized-oauth-ous' %}">{% trans "Manage organizational unit authorizations" %}</a></p>
+      {% endif %}
       {% if allow_account_deletion %}
         <p><a href="{% url 'delete_account' %}">{% trans "Delete account" %}</a></p>
       {% endif %}
diff --git a/src/authentic2/templates/authentic2/accounts_authorized_oauth_ous.html b/src/authentic2/templates/authentic2/accounts_authorized_oauth_ous.html
new file mode 100644
index 000000000..7648c7b44
--- /dev/null
+++ b/src/authentic2/templates/authentic2/accounts_authorized_oauth_ous.html
@@ -0,0 +1,67 @@
+{% extends "authentic2/base-page.html" %}
+{% load i18n %}
+
+{% block page-title %}
+{{ block.super }} - {{ view.title }}
+{% endblock %}
+
+{% block breadcrumb %}
+{{ block.super }}
+<a href="..">{% trans "Your account" %}</a>
+<a href="">{{ view.title }}</a>
+{% endblock %}
+
+{% block content %}
+{% block oidc-authorized-oauth-ous-pre %}{% endblock %}
+<div class="authorized-oauth-ous">
+  {% block oidc-authorized-oauth-ous-top %}
+  <p class="authorized-oauth-ous--top">
+    {% if authorized_oauth_ous|length_is:0 %}
+    {% trans "You have not granted organizational unit access to your account profile data." %}
+    {% else %}
+    {% blocktrans count counter=authorized_oauth_ous|length %}
+    You have granted one organizational unit access to your account profile data.
+    {% plural %}
+    You have granted {{ counter }} organizational units access to your account profile data.
+    {% endblocktrans %}
+    {% endif %}
+  </p>
+  {% endblock %}
+  <ul class="authorized-oauth-ous--list">
+    {% for auth in authorized_oauth_ous %}
+    <li class="authorized-oauth-ous--item">
+      <form method="post" class="authorized-oauth-ous--form">
+        {% csrf_token %}
+        {% block oidc-authorized-oauth-ou %}
+        <div class="authorized-oauth-ous--infos">
+          {% block oidc-authorized-oauth-ou-top %}{% endblock %}
+          <span class="authorized-oauth-ous--client">
+            {{ auth.client }}
+          </span>
+          <span class="authorized-oauth-ous--dates">
+            <span class="authorized-oauth-ous-dates--since">
+              <span class="label">{% trans "Allowed since:" %}</span>
+              <span class="time">{{ auth.created }}</span>
+            </span>
+            <span class="authorized-oauth-ous--separator">/</span>
+            <span class="authorized-oauth-ous--expired">
+              <span class="label">{% trans "Expire on:" %}</span>
+              <span class="time">{{ auth.expired }}</span>
+            </span>
+          </span>
+        </div>
+        <div class="authorized-oauth-ous--actions">
+          <input type="hidden" id="auth-id" name="auth_id" value="{{ auth.id }}">
+          <button class="authorized-oauth-ous--revoke-button">{% trans 'Revoke' %}</button>
+        </div>
+        {% block oidc-authorized-oauth-ou-bottom %}{% endblock %}
+        {% endblock %}
+      </form>
+    </li>
+    {% endfor %}
+  </ul>
+</table>
+{% block oidc-authorized-oauth-ous-bottom %}{% endblock %}
+</div>
+{% block oidc-authorized-oauth-ous-post %}{% endblock %}
+{% endblock %}
diff --git a/src/authentic2/urls.py b/src/authentic2/urls.py
index 66c40be3a..ccda1f35f 100644
--- a/src/authentic2/urls.py
+++ b/src/authentic2/urls.py
@@ -69,9 +69,12 @@ accounts_urlpatterns = [
     url(r'^change-email/verify/$',
         views.email_change_verify,
         name='email-change-verify'),
-    url(r'^authorizations/$',
+    url(r'^service-authorizations/$',
         login_required(views.authorized_oauth_services),
         name='authorized-oauth-services'),
+    url(r'^ou-authorizations/$',
+        login_required(views.authorized_oauth_ous),
+        name='authorized-oauth-ous'),
     url(r'^$',
         views.profile,
         name='account_management'),
diff --git a/src/authentic2/views.py b/src/authentic2/views.py
index 614993546..8613d02bf 100644
--- a/src/authentic2/views.py
+++ b/src/authentic2/views.py
@@ -513,11 +513,15 @@ class ProfileView(cbv.TemplateNamesMixin, TemplateView):
             'federation_management': federation_management,
         })
 
-        if ('authentic2_idp_oidc' in settings.INSTALLED_APPS and
-                app_settings.A2_PROFILE_DISPLAY_SERVICE_AUTHORIZATIONS_PAGE):
-            from authentic2_idp_oidc.models import OIDCClient
-            context['allow_authorization_management'] = OIDCClient.objects.filter(
-                    authorization_mode=OIDCClient.AUTHORIZATION_MODE_BY_SERVICE).exists()
+        if 'authentic2_idp_oidc' in settings.INSTALLED_APPS:
+            if app_settings.A2_PROFILE_DISPLAY_SERVICE_AUTHORIZATIONS_PAGE:
+                from authentic2_idp_oidc.models import OIDCClient
+                context['allow_authorization_management'] = OIDCClient.objects.filter(
+                        authorization_mode=OIDCClient.AUTHORIZATION_MODE_BY_SERVICE).exists()
+            if app_settings.A2_PROFILE_DISPLAY_OU_AUTHORIZATIONS_PAGE:
+                from authentic2_idp_oidc.models import OIDCClient
+                context['allow_authorization_management_by_ou'] = OIDCClient.objects.filter(
+                        authorization_mode=OIDCClient.AUTHORIZATION_MODE_BY_OU).exists()
 
         hooks.call_hooks('modify_context_data', self, context)
         return context
@@ -1309,3 +1313,34 @@ class AuthorizedOauthServicesView(TemplateView):
 
 
 authorized_oauth_services = AuthorizedOauthServicesView.as_view()
+
+
+class AuthorizedOauthOusView(TemplateView):
+    template_name = 'authentic2/accounts_authorized_oauth_ous.html'
+    title = _('Consent Management by organizational units')
+
+    def get_context_data(self, **kwargs):
+        from authentic2_idp_oidc.models import OIDCAuthorization
+        from django_rbac.utils import get_ou_model
+
+        context = super(AuthorizedOauthOusView, self).get_context_data(**kwargs)
+        ou_ct = ContentType.objects.get_for_model(get_ou_model())
+        context['authorized_oauth_ous'] = OIDCAuthorization.objects.filter(
+            user=self.request.user, client_ct=ou_ct)
+        return context
+
+    def post(self, request, *args, **kwargs):
+        from authentic2_idp_oidc.models import OIDCAuthorization
+        from django_rbac.utils import get_ou_model
+
+        ou_ct = ContentType.objects.get_for_model(get_ou_model())
+        qs = OIDCAuthorization.objects.filter(
+            user=request.user, client_ct=ou_ct)
+        auth_id = request.POST.get('auth_id')
+        if auth_id:
+            qs = qs.filter(id=auth_id)
+        qs.delete()
+        return HttpResponseRedirect(reverse('authorized-oauth-ous'))
+
+
+authorized_oauth_ous = AuthorizedOauthOusView.as_view()
diff --git a/tests/test_idp_oidc.py b/tests/test_idp_oidc.py
index 98908bfd4..d94afaab3 100644
--- a/tests/test_idp_oidc.py
+++ b/tests/test_idp_oidc.py
@@ -1647,3 +1647,52 @@ def test_oidc_authorized_oauth_services_view(app, oidc_client, simple_user):
     assert len(response.html.find_all(
         'button', {'class': 'authorized-oauth-services--revoke-button'})) == 1
     assert "You have granted one service access to your account profile data." in response.text
+
+
+def test_oidc_authorized_oauth_ous_view(app, oidc_client, simple_user):
+    url = make_url('authorized-oauth-ous')
+    response = app.get(url, status=302)
+    assert '/login/' in response.location
+
+    utils.login(app, simple_user)
+    response = app.get(url, status=200)
+    assert "You have not granted organizational unit access to your account profile data." in response.text
+
+    OU = get_ou_model()
+    ou1 = OU.objects.create(name='Orgunit1', slug='orgunit1')
+    ou2 = OU.objects.create(name='Orgunit2', slug='orgunit2')
+    ou3 = OU.objects.create(name='Orgunit3', slug='orgunit3')
+
+    oidc_client.authorization_mode = OIDCClient.AUTHORIZATION_MODE_BY_OU
+    oidc_client.ou = ou1
+    oidc_client.save()
+
+    OIDCAuthorization.objects.create(
+        client=ou1, user=simple_user, scopes='openid',
+        expired=now() + datetime.timedelta(days=2))
+    OIDCAuthorization.objects.create(
+        client=ou2, user=simple_user, scopes='openid profile',
+        expired=now() + datetime.timedelta(days=2))
+    OIDCAuthorization.objects.create(
+        client=ou3, user=simple_user, scopes='openid profile email',
+        expired=now() + datetime.timedelta(days=2))
+    # create a service-based authz that should not appear here
+    OIDCAuthorization.objects.create(
+        client=oidc_client, user=simple_user, scopes='openid profile email',
+        expired=now() + datetime.timedelta(days=2))
+
+    response = app.get(url, status=200)
+    assert "You have granted 3 organizationat units access to your account profile data."
+    assert len(response.html.find_all(
+        'button', {'class': 'authorized-oauth-ous--revoke-button'})) == 3
+
+    # revoke two
+    response = response.forms[0].submit()
+    response = response.follow()
+    assert len(response.html.find_all(
+        'button', {'class': 'authorized-oauth-ous--revoke-button'})) == 2
+    response = response.forms[0].submit()
+    response = response.follow()
+    assert len(response.html.find_all(
+        'button', {'class': 'authorized-oauth-ous--revoke-button'})) == 1
+    assert "You have granted one organizational unit access to your account profile data." in response.text
diff --git a/tests/test_profile.py b/tests/test_profile.py
index 394d41fa4..e46fd6115 100644
--- a/tests/test_profile.py
+++ b/tests/test_profile.py
@@ -231,6 +231,17 @@ def test_account_view(app, simple_user, settings):
         reverse('delete_account')
     ]
 
+    # oidc client defined for ou-based authz management
+    client.authorization_mode = OIDCClient.AUTHORIZATION_MODE_BY_OU
+    client.save()
+    response = app.get(url, status=200)
+    assert [x['href'] for x in response.html.find('div', {'id': 'a2-profile'}).find_all('a')] == [
+        reverse('email-change'),
+        reverse('profile_edit'),
+        reverse('authorized-oauth-ous'),
+        reverse('delete_account')
+    ]
+
     # restore authorization mode
     client.authorization_mode = OIDCClient.AUTHORIZATION_MODE_BY_SERVICE
     client.save()
-- 
2.28.0

