From 63424908a11a604d23c1fe3405ebf08804c81437 Mon Sep 17 00:00:00 2001
From: Nicolas ROCHE <nroche@entrouvert.com>
Date: Sat, 28 Nov 2020 14:47:10 +0100
Subject: [PATCH 1/3] admin: manage api access keys (#48751)

---
 tests/admin_pages/test_api_access.py          | 166 +++++++++++++++++
 wcs/admin/api_access.py                       | 173 ++++++++++++++++++
 wcs/admin/settings.py                         |   9 +-
 wcs/api_access.py                             |  41 +++++
 wcs/templates/wcs/backoffice/api_access.html  |  22 +++
 .../wcs/backoffice/api_accesses.html          |  22 +++
 6 files changed, 432 insertions(+), 1 deletion(-)
 create mode 100644 tests/admin_pages/test_api_access.py
 create mode 100644 wcs/admin/api_access.py
 create mode 100644 wcs/api_access.py
 create mode 100644 wcs/templates/wcs/backoffice/api_access.html
 create mode 100644 wcs/templates/wcs/backoffice/api_accesses.html

diff --git a/tests/admin_pages/test_api_access.py b/tests/admin_pages/test_api_access.py
new file mode 100644
index 00000000..af08410b
--- /dev/null
+++ b/tests/admin_pages/test_api_access.py
@@ -0,0 +1,166 @@
+# w.c.s. - web application for online forms
+# Copyright (C) 2005-2020  Entr'ouvert
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, see <http://www.gnu.org/licenses/>.
+
+import pytest
+
+from wcs.qommon.http_request import HTTPRequest
+from wcs.api_access import ApiAccess
+
+from utilities import get_app, login, create_temporary_pub, clean_temporary_pub
+from .test_all import create_superuser
+
+
+def pytest_generate_tests(metafunc):
+    if 'pub' in metafunc.fixturenames:
+        metafunc.parametrize('pub', ['pickle', 'sql', 'pickle-templates'], indirect=True)
+
+
+@pytest.fixture
+def pub(request):
+    pub = create_temporary_pub(
+        sql_mode=bool('sql' in request.param),
+        templates_mode=bool('templates' in request.param)
+    )
+
+    req = HTTPRequest(None, {'SCRIPT_NAME': '/', 'SERVER_NAME': 'example.net'})
+    pub.set_app_dir(req)
+    pub.cfg['identification'] = {'methods': ['password']}
+    pub.cfg['language'] = {'language': 'en'}
+    pub.write_cfg()
+
+    return pub
+
+
+def teardown_module(module):
+    clean_temporary_pub()
+
+
+@pytest.fixture
+def api_access():
+    ApiAccess.wipe()
+    obj = ApiAccess()
+    obj.name = 'Jhon'
+    obj.description = 'API key for Jhon'
+    obj.access_identifier = 'jhon'
+    obj.access_key = '12345'
+    obj.store()
+    return obj
+
+
+def test_api_access_new(pub):
+    create_superuser(pub)
+    ApiAccess.wipe()
+    app = login(get_app(pub))
+
+    # go to the page and cancel
+    resp = app.get('/backoffice/settings/api-access/')
+    resp = resp.click('New API access')
+    resp = resp.forms[0].submit('cancel')
+    assert resp.location == 'http://example.net/backoffice/settings/api-access/'
+
+    # go to the page and add an API access
+    resp = app.get('/backoffice/settings/api-access/')
+    resp = resp.click('New API access')
+    resp.form['name'] = 'a new API access'
+    resp.form['description'] = 'description'
+    resp.form['access_identifier'] = 'new_access'
+    resp.form['access_key'] = '1234'
+    resp = resp.form.submit('submit')
+    assert resp.location == 'http://example.net/backoffice/settings/api-access/'
+    resp = resp.follow()
+    assert 'a new API access' in resp.text
+    resp = resp.click('a new API access')
+    assert 'API access - a new API access' in resp.text
+
+    # check name unicity
+    resp = app.get('/backoffice/settings/api-access/new')
+    resp.form['name'] = 'a new API access'
+    resp.form['access_identifier'] = 'changed'
+    resp.form['access_key'] = '1234'
+    resp = resp.form.submit('submit')
+    assert resp.html.find('div', {'class': 'error'}).text == 'This name is already used.'
+
+    # check access_identifier unicity
+    resp.form['name'] = 'new one'
+    resp.form['access_identifier'] = 'new_access'
+    resp = resp.form.submit('submit')
+    assert resp.html.find('div', {'class': 'error'}).text == 'This value is already used.'
+
+
+def test_api_access_view(pub, api_access):
+    create_superuser(pub)
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/settings/api-access/%s/' % api_access.id)
+    assert '12345' in resp.text
+
+    resp = app.get('/backoffice/settings/api-access/wrong-id/', status=404)
+
+
+def test_api_access_edit(pub, api_access):
+    create_superuser(pub)
+
+    app = login(get_app(pub))
+
+    resp = app.get('/backoffice/settings/api-access/1/')
+    resp = resp.click(href='edit')
+    assert resp.form['name'].value == 'Jhon'
+    resp = resp.form.submit('cancel')
+    assert resp.location == 'http://example.net/backoffice/settings/api-access/1/'
+    resp = resp.follow()
+    resp = resp.click(href='edit')
+    resp.form['name'] = 'Smith Robert'
+    resp.form['description'] = 'bla bla bla'
+    resp.form['access_identifier'] = 'smith2'
+    resp.form['access_key'] = '5678'
+    resp = resp.form.submit('submit')
+    assert resp.location == 'http://example.net/backoffice/settings/api-access/1/'
+    resp = resp.follow()
+
+    api_access = ApiAccess.get('1')
+    assert api_access.name == 'Smith Robert'
+    assert api_access.description == 'bla bla bla'
+    assert api_access.access_identifier == 'smith2'
+    assert api_access.access_key == '5678'
+
+    # check name unicity
+    resp = app.get('/backoffice/settings/api-access/new')
+    resp.form['name'] = 'Jhon'
+    resp.form['access_identifier'] = 'jhon'
+    resp.form['access_key'] = '1234'
+    resp = resp.form.submit('submit')
+    resp = app.get('/backoffice/settings/api-access/1/')
+    resp = resp.click(href='edit')
+    resp.form['name'] = 'Jhon'
+    resp = resp.form.submit('submit')
+    assert resp.html.find('div', {'class': 'error'}).text == 'This name is already used.'
+
+
+def test_api_access_delete(pub, api_access):
+    create_superuser(pub)
+
+    app = login(get_app(pub))
+
+    resp = app.get('/backoffice/settings/api-access/1/')
+    resp = resp.click(href='delete')
+    resp = resp.form.submit('cancel')
+    assert resp.location == 'http://example.net/backoffice/settings/api-access/'
+
+    resp = app.get('/backoffice/settings/api-access/1/')
+    resp = resp.click(href='delete')
+    resp = resp.form.submit('submit')
+    assert resp.location == 'http://example.net/backoffice/settings/api-access/'
+    assert ApiAccess.count() == 0
diff --git a/wcs/admin/api_access.py b/wcs/admin/api_access.py
new file mode 100644
index 00000000..44574763
--- /dev/null
+++ b/wcs/admin/api_access.py
@@ -0,0 +1,173 @@
+# w.c.s. - web application for online forms
+# Copyright (C) 2005-2020  Entr'ouvert
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, see <http://www.gnu.org/licenses/>.
+
+from quixote import get_response, redirect
+from quixote.directory import Directory
+from quixote.html import TemplateIO, htmltext
+
+from wcs.qommon import _, errors, template
+from wcs.qommon.form import Form, StringWidget, TextWidget, HtmlWidget
+from wcs.qommon.backoffice.menu import html_top
+from wcs.api_access import ApiAccess
+
+
+class ApiAccessUI(object):
+    def __init__(self, api_access):
+        self.api_access = api_access
+        if self.api_access is None:
+            self.api_access = ApiAccess()
+
+    def get_form(self):
+        form = Form(enctype='multipart/form-data',
+                    advanced_label=_('Additional options'))
+        form.add(StringWidget, 'name', title=_('Name'), required=True, size=30,
+                 value=self.api_access.name)
+        form.add(TextWidget, 'description', title=_('Description'),
+                 cols=40, rows=5,
+                 value=self.api_access.description)
+        form.add(StringWidget, 'access_identifier', title=_('Access identifier'),
+                 required=True, size=30,
+                 value=self.api_access.access_identifier)
+        form.add(StringWidget, 'access_key', title=_('Access key'), required=True, size=30,
+                 value=self.api_access.access_key)
+        if not self.api_access.is_readonly():
+            form.add_submit('submit', _('Submit'))
+        form.add_submit('cancel', _('Cancel'))
+        return form
+
+    def submit_form(self, form):
+        name = form.get_widget('name').parse()
+        access_identifier = form.get_widget('access_identifier').parse()
+
+        for api_access in ApiAccess.select():
+            if api_access.id == self.api_access.id:
+                continue
+            if name == api_access.name:
+                form.get_widget('name').set_error(_('This name is already used.'))
+            if access_identifier and access_identifier == api_access.access_identifier:
+                form.get_widget('access_identifier').set_error(_('This value is already used.'))
+        if form.has_errors():
+            raise ValueError()
+
+        self.api_access.name = name
+        self.api_access.description = form.get_widget('description').parse()
+        self.api_access.access_identifier = access_identifier
+        self.api_access.access_key = form.get_widget('access_key').parse()
+        self.api_access.store()
+
+
+class ApiAccessPage(Directory):
+    _q_exports = ['', 'edit', 'delete',]
+
+    def __init__(self, component, instance=None):
+        try:
+            self.api_access = instance or ApiAccess.get(component)
+        except KeyError:
+            raise errors.TraversalError()
+        self.api_access_ui = ApiAccessUI(self.api_access)
+        get_response().breadcrumb.append((component + '/', self.api_access.name))
+
+    def get_sidebar(self):
+        r = TemplateIO(html=True)
+        r += htmltext('<ul id="sidebar-actions">')
+        r += htmltext('<li><a href="delete" rel="popup">%s</a></li>') % _('Delete')
+        r += htmltext('</ul>')
+        return r.getvalue()
+
+    def _q_index(self):
+        html_top('api-accesss', title=self.api_access.name)
+        get_response().filter['sidebar'] = self.get_sidebar()
+        return template.QommonTemplateResponse(
+            templates=['wcs/backoffice/api_access.html'],
+            context={'view': self, 'api_access': self.api_access})
+
+    def edit(self):
+        form = self.api_access_ui.get_form()
+        if form.get_submit() == 'cancel':
+            return redirect('.')
+
+        if form.get_submit() == 'submit' and not form.has_errors():
+            try:
+                self.api_access_ui.submit_form(form)
+            except ValueError:
+                pass
+            else:
+                return redirect('../%s/' % self.api_access.id)
+
+        get_response().breadcrumb.append(('edit', _('Edit')))
+        html_top('api-access', title=_('Edit API access'))
+        r = TemplateIO(html=True)
+        r += htmltext('<h2>%s</h2>') % _('Edit API access')
+        r += form.render()
+        return r.getvalue()
+
+    def delete(self):
+        form = Form(enctype='multipart/form-data')
+        form.widgets.append(HtmlWidget('<p>%s</p>' % _(
+            'You are about to irrevocably delete this API access.')))
+        form.add_submit('delete', _('Delete'))
+        form.add_submit('cancel', _('Cancel'))
+        if form.get_widget('cancel').parse():
+            return redirect('..')
+        if not form.is_submitted() or form.has_errors():
+            get_response().breadcrumb.append(('delete', _('Delete')))
+            html_top('api_accesss', title=_('Delete API access'))
+            r = TemplateIO(html=True)
+            r += htmltext('<h2>%s %s</h2>') % (
+                _('Deleting API access:'), self.api_access.name)
+            r += form.render()
+            return r.getvalue()
+        else:
+            self.api_access.remove_self()
+            return redirect('..')
+
+
+class ApiAccessDirectory(Directory):
+    _q_exports = ['', 'new']
+
+    def _q_traverse(self, path):
+        get_response().breadcrumb.append(('api-access/', _('API access')))
+        return super()._q_traverse(path)
+
+    def _q_index(self):
+        html_top('api-access', title=_('API access'))
+        return template.QommonTemplateResponse(
+            templates=['wcs/backoffice/api_accesses.html'],
+            context={'view': self, 'api_accesses': ApiAccess.select(order_by='name')})
+
+    def new(self):
+        get_response().breadcrumb.append(('new', _('New')))
+        api_access_ui = ApiAccessUI(None)
+        form = api_access_ui.get_form()
+        if form.get_widget('cancel').parse():
+            return redirect('.')
+
+        if form.get_submit() == 'submit' and not form.has_errors():
+            try:
+                api_access_ui.submit_form(form)
+            except ValueError:
+                pass
+            else:
+                return redirect('.')
+
+        html_top('api-access', title=_('New API access'))
+        r = TemplateIO(html=True)
+        r += htmltext('<h2>%s</h2>') % _('New API access')
+        r += form.render()
+        return r.getvalue()
+
+    def _q_lookup(self, component):
+        return ApiAccessPage(component)
diff --git a/wcs/admin/settings.py b/wcs/admin/settings.py
index a47a84cb..f520813a 100644
--- a/wcs/admin/settings.py
+++ b/wcs/admin/settings.py
@@ -57,16 +57,17 @@ from wcs.formdef import FormDef
 from wcs.carddef import CardDef
 from wcs.workflows import Workflow, WorkflowImportError
 from wcs.roles import Role
 
 from wcs.backoffice.studio import StudioDirectory
 from .fields import FieldDefPage, FieldsDirectory
 from .data_sources import NamedDataSourcesDirectory
 from .wscalls import NamedWsCallsDirectory
+from .api_access import ApiAccessDirectory
 
 
 class UserFormDirectory(Directory):
     _q_exports = ['']
 
 
 class IdentificationDirectory(Directory):
     _q_exports = ['']
@@ -426,27 +427,29 @@ class SettingsDirectory(QommonSettingsDirectory):
     _q_exports = ['', 'themes', 'users',
             'template', 'emails', 'debug_options', 'language',
             ('import', 'p_import'), 'export', 'identification', 'sitename',
             'sms', 'certificates', 'texts', 'install_theme',
             'session', 'download_theme', 'smstest', 'postgresql',
             ('admin-permissions', 'admin_permissions'), 'geolocation',
             'theme_preview', 'filetypes',
             ('user-template', 'user_template'),
-            ('data-sources', 'data_sources'), 'wscalls', 'logs']
+            ('data-sources', 'data_sources'), 'wscalls', 'logs',
+            ('api-access', 'api_access')]
 
     emails = EmailsDirectory()
     identification = IdentificationDirectory()
     users = UsersDirectory()
     texts = TextsDirectory()
     theme_preview = ThemePreviewDirectory()
     filetypes = FileTypesDirectory()
     data_sources = NamedDataSourcesDirectory()
     wscalls = NamedWsCallsDirectory()
     logs = LoggerDirectory()
+    api_access = ApiAccessDirectory()
 
     def _q_index(self):
         html_top('settings', title = _('Settings'))
         r = TemplateIO(html=True)
 
         disabled_screens_option = get_publisher().get_site_option('settings-disabled-screens') or ''
         disabled_screens = [x.strip() for x in disabled_screens_option.split(',')]
 
@@ -486,16 +489,20 @@ class SettingsDirectory(QommonSettingsDirectory):
                     _('Session'), _('Configure session management'))
 
         if enabled('permissions'):
             roles = list(Role.select())
             if roles:
                 r += htmltext('<dt><a href="admin-permissions">%s</a></dt> <dd>%s</dd>') % (
                     _('Admin Permissions'), _('Configure access to the administration interface'))
 
+        if enabled('api-access'):
+            r += htmltext('<dt><a href="api-access">%s</a></dt> <dd>%s</dd>') % (
+                _('API access'), _('Configure access to the API endpoints'))
+
         r += htmltext('</dl></div>')
 
         if enabled('import-export'):
             r += htmltext('<div class="section">')
             r += htmltext('<h2>%s</h2>') % _('Import / Export')
 
             r += htmltext('<dl>')
             r += htmltext('<dt><a href="import">%s</a></dt> <dd>%s</dd>') % (
diff --git a/wcs/api_access.py b/wcs/api_access.py
new file mode 100644
index 00000000..2fd9f8e1
--- /dev/null
+++ b/wcs/api_access.py
@@ -0,0 +1,41 @@
+# w.c.s. - web application for online forms
+# Copyright (C) 2005-2020  Entr'ouvert
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, see <http://www.gnu.org/licenses/>.
+
+from wcs.qommon.xml_storage import XmlStorableObject
+
+
+class ApiAccess(XmlStorableObject):
+    _names = 'apiaccess'
+    xml_root_node = 'apiaccess'
+    name = None
+    access_identifier = None
+    access_key = None
+    description = None
+
+    # declarations for serialization
+    XML_NODES = [
+        ('name', 'str'),
+        ('description', 'str'),
+        ('access_identifier', 'str'),
+        ('access_key', 'str'),
+    ]
+
+    @classmethod
+    def get_access_key(cls, access_identifier):
+        for api_access in cls.select():
+            if api_access.access_identifier == access_identifier:
+                return api_access.access_key
+        return None
diff --git a/wcs/templates/wcs/backoffice/api_access.html b/wcs/templates/wcs/backoffice/api_access.html
new file mode 100644
index 00000000..235fcf13
--- /dev/null
+++ b/wcs/templates/wcs/backoffice/api_access.html
@@ -0,0 +1,22 @@
+{% load i18n %}
+
+{% block body %}
+<div id="appbar">
+  <h2>{% trans "API access" %} - {{ api_access.name }}</h2>
+<span class="actions">
+  <a href="edit">{% trans "Edit" %}</a>
+</span>
+</div>
+
+{% if api_access.description %}
+<div class="bo-block">{{ api_access.description }}</div>
+{% endif %}
+
+<div class="bo-block">
+  <h3>{% trans "Parameters" %}</h3>
+  <ul>
+    <li>{% trans "Access identifier:" %} {{ api_access.access_identifier }}</li>
+    <li>{% trans "Access key:" %} {{ api_access.access_key }}</li>
+  </ul>
+</div>
+{% endblock %}
diff --git a/wcs/templates/wcs/backoffice/api_accesses.html b/wcs/templates/wcs/backoffice/api_accesses.html
new file mode 100644
index 00000000..1e0a96d0
--- /dev/null
+++ b/wcs/templates/wcs/backoffice/api_accesses.html
@@ -0,0 +1,22 @@
+{% extends "wcs/backoffice/base.html" %}
+{% load i18n %}
+
+{% block appbar-title %}{% trans "API access" %}{% endblock %}
+
+{% block appbar-actions %}
+<a rel="popup" href="new">{% trans "New API access" %}</a>
+{% endblock %}
+
+{% block content %}
+{% if api_accesses %}
+<ul class="objects-list single-links">
+  {% for api_access in api_accesses %}
+  <li><a href="{{ api_access.id }}/">{{ api_access.name }} ({{ api_access.access_identifier }})</a></li>
+  {% endfor %}
+</ul>
+{% else %}
+<div class="infonotice">
+{% trans "There are no API access defined." %}
+</div>
+{% endif %}
+{% endblock %}
-- 
2.29.2

