commit a0391b2bdd435004eb283c7274c40c6f69d0f805 Author: Paul Marillonnet Date: Wed Apr 21 10:51:48 2021 +0200 initial commit, draft support for oauth and rest sources diff --git a/code-style.sh b/code-style.sh new file mode 100755 index 0000000..9916cf8 --- /dev/null +++ b/code-style.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +black --target-version py37 --skip-string-normalization --line-length 110 . + +isort --profile black --line-length 110 . diff --git a/getlasso3.sh b/getlasso3.sh new file mode 100755 index 0000000..9266a72 --- /dev/null +++ b/getlasso3.sh @@ -0,0 +1,22 @@ +#!/bin/sh + +# Get venv site-packages path +DSTDIR=`python3 -c 'from distutils.sysconfig import get_python_lib; print(get_python_lib())'` + +# Get not venv site-packages path +# Remove first path (assuming that is the venv path) +NONPATH=`echo $PATH | sed 's/^[^:]*://'` +SRCDIR=`PATH=$NONPATH python3 -c 'from distutils.sysconfig import get_python_lib; print(get_python_lib())'` + +# Clean up +rm -f $DSTDIR/lasso.* +rm -f $DSTDIR/_lasso.* + +# Link +ln -sv /usr/lib/python3/dist-packages/lasso.py $DSTDIR/ +for SOFILE in /usr/lib/python3/dist-packages/_lasso.cpython-*.so +do + ln -sv $SOFILE $DSTDIR/ +done + +exit 0 diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po new file mode 100644 index 0000000..ae840c8 --- /dev/null +++ b/locale/fr/LC_MESSAGES/django.po @@ -0,0 +1,281 @@ +# pii manager poc french l10n +# Copyright (C) 2021 +# This file is distributed under the same license as the pii-manager-poc package. +# Paul Marillonnet , 2021. +# +msgid "" +msgstr "" +"Project-Id-Version: pii manager poc\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-04-29 15:14+0000\n" +"PO-Revision-Date: 2021-04-29 17:02+0200\n" +"Last-Translator: Paul Marillonnet \n" +"Language-Team: None\n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: manager/core/models.py:29 +msgid "OAuth" +msgstr "OAuth" + +#: manager/core/models.py:30 +msgid "SAML" +msgstr "SAML" + +#: manager/core/models.py:31 +msgid "REST" +msgstr "REST" + +#: manager/core/models.py:32 +msgid "Kerberos" +msgstr "Kerberos" + +#: manager/core/models.py:41 +msgid "source name" +msgstr "nom de la source" + +#: manager/core/models.py:48 +msgid "source type" +msgstr "type de source" + +#: manager/core/models.py:53 +msgid "source url" +msgstr "url de la source" + +#: manager/core/models.py:64 +msgid "provider name" +msgstr "nom du fournisseur" + +#: manager/core/models.py:69 +msgid "provider url" +msgstr "url du fournisseur" + +#: manager/core/models.py:108 +msgid "street number" +msgstr "numéro de rue" + +#: manager/core/models.py:113 +msgid "street name" +msgstr "nom de rue" + +#: manager/core/models.py:118 +msgid "zip code" +msgstr "code postal" + +#: manager/core/models.py:123 +msgid "city" +msgstr "ville" + +#: manager/core/models.py:128 +msgid "region" +msgstr "région" + +#: manager/core/models.py:133 +msgid "country" +msgstr "pays" + +#: manager/core/models.py:142 +msgid "public key" +msgstr "clé publique" + +#: manager/core/models.py:147 +msgid "contact name of the pii controller" +msgstr "nom du contact du responsable de traitement" + +#: manager/core/models.py:151 +msgid "address of the pii controller" +msgstr "adresse du responsable de traitement" + +#: manager/core/models.py:155 +msgid "pii controller contact email address" +msgstr "adresse courriel de contact du responsable de traitement" + +#: manager/core/models.py:159 +msgid "pii controller contact phone number" +msgstr "numéro de téléphone de contact du responsable de traitement" + +#: manager/core/models.py:163 +msgid "pii controller contact url" +msgstr "url de contact du responsable de traitement" + +#: manager/core/models.py:167 +msgid "link to the pii controller's privacy statement" +msgstr "lien vers la politique de gestion des données personnelles du responsable de traitement" + +#: manager/core/models.py:175 +msgid "name of the service" +msgstr "nom du service" + +#: manager/core/models.py:180 +msgid "client identifier of the service" +msgstr "identifiant du service (client id)" + +#: manager/core/models.py:185 +msgid "client secret of the service" +msgstr "secret du service (client secret)" + +#: manager/core/models.py:197 +msgid "authorization code for the service" +msgstr "code d’autorisation pour le service" + +#: manager/core/models.py:203 +msgid "value of the authorization code" +msgstr "valeur du code d’autorisation" + +#: manager/core/models.py:207 +msgid "authorization code expiry timestamp" +msgstr "horodate d’expiration du code d’autorisation" + +#: manager/core/models.py:217 +msgid "access token" +msgstr "jeton d’accès" + +#: manager/core/models.py:221 +msgid "scopes for the access token" +msgstr "portées du jeton d’accès" + +#: manager/core/models.py:225 +msgid "expiration timestamp" +msgstr "horodate d’expiration" + +#: manager/core/models.py:228 +msgid "audience of the token" +msgstr "audience du jeton" + +#: manager/core/models.py:232 +msgid "token not usable before" +msgstr "jeton inutilisable avant le" + +#: manager/core/models.py:251 +msgid "core function" +msgstr "fonction cœur" + +#: manager/core/models.py:252 +msgid "contracted service" +msgstr "service en contrat" + +#: manager/core/models.py:253 +msgid "delivery" +msgstr "livraison" + +#: manager/core/models.py:254 +msgid "contact requested" +msgstr "contact demandé" + +#: manager/core/models.py:255 +msgid "personalized experience" +msgstr "expérience personnalisée" + +#: manager/core/models.py:256 +msgid "marketing" +msgstr "marketing" + +#: manager/core/models.py:263 +msgid "short description of the pii collection purpose" +msgstr "description brève de la finalité de collecte des données" + +#: manager/core/models.py:278 +msgid "target category of pii" +msgstr "catégorie cible de données" + +#: manager/core/models.py:281 +msgid "sensitivity of pii" +msgstr "niveau de sensibilité des données" + +#: manager/core/models.py:290 +msgid "conditions for termination of consent" +msgstr "conditions de terminaison du consentement" + +#: manager/core/models.py:298 +msgid "implicit" +msgstr "implicite" + +#: manager/core/models.py:299 +msgid "explicit" +msgstr "explicite" + +#: manager/core/models.py:309 +msgid "receipt version" +msgstr "version de reçu" + +#: manager/core/models.py:314 +msgid "jurisdiction applying for the receipt" +msgstr "juridiction d’application du reçu" + +#: manager/core/models.py:319 +msgid "consent timestamp" +msgstr "horodate du consentement" + +#: manager/core/models.py:325 +msgid "collection method" +msgstr "méthode de collecte" + +#: manager/core/models.py:342 +msgid "language in which the consent was obtained" +msgstr "langue dans laquelle le consentement a été obtenu" + +#: manager/core/models.py:351 +msgid "pii principal-provided identifier" +msgstr "identifiant des données fournies par le sujet" + +#: manager/core/models.py:358 +msgid "fk to the first pii controller that collects the data" +msgstr "clé étrangère vers le premier responsable de traitement ayant collecté les donées" + +#: manager/core/models.py:366 +msgid "a pii processor acting on behalf of another processor or controller" +msgstr "au moins un des responsables de traitement agit pour le compte d’un autre responsable de traitement" + +#: manager/core/models.py:377 +msgid "service or group of services for which PII is collected" +msgstr "service ou groupe de services pour le(s)quel(s) les données sont collectées" + +#: manager/core/models.py:387 +msgid "purpose of collection" +msgstr "finalité de la collecte" + +#: manager/core/models.py:403 +msgid "termination policy" +msgstr "politique de terminaison" + +#: manager/core/models.py:407 +msgid "the pii controller is disclosing pii to a third party" +msgstr "le responsable de traitement divulgue les données à un tiers-parti" + +#: manager/core/models.py:412 +msgid "" +"name or names of the third party to which the controller may disclose the pii" +msgstr "" +"nom ou noms du tiers-parti auquel le responsable de traitement divulgue les données" + +#: manager/core/templates/manager/core/authorize.html:7 +#, python-format +msgid "" +"\n" +"

The service %(service)s is requesting access to scopes %(scopes)s " +"on your personal information.

\n" +" " +msgstr "" +"\n" +"

Le service %(service)s demande l’accès aux portées %(scopes)s " +"concernant vos données personnelles.

\n" +" " + +#: manager/core/templates/manager/core/authorize.html:16 +msgid "Choose" +msgstr "Effectuer ce choix" + +#: manager/core/templates/manager/core/authorize.html:17 +msgid "Cancel" +msgstr "Annuler" + +#: manager/templates/manager/root.html:28 +msgid "Logout" +msgstr "Déconnexion" + +#: manager/templates/manager/root.html:49 +msgid "Homepage" +msgstr "Page d’accueil" diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..b7e883a --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "manager.settings") + try: + from django.core.management import execute_from_command_line + except ImportError: + # The above import may fail for some other reason. Ensure that the + # issue is really that Django is missing to avoid masking other + # exceptions on Python 2. + try: + import django + except ImportError: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) + raise + execute_from_command_line(sys.argv) diff --git a/manager/__init__.py b/manager/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manager/core/__init__.py b/manager/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manager/core/admin.py b/manager/core/admin.py new file mode 100644 index 0000000..0190bf1 --- /dev/null +++ b/manager/core/admin.py @@ -0,0 +1,58 @@ +# pii manager - proof-of-concept implementation +# Copyright (C) 2021 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.contrib import admin + +from manager.core import models + + +class OAuthSourceAdmin(admin.ModelAdmin): + list_display = ['name', 'url'] + list_filter = ['name'] + + +class RestSourceAdmin(admin.ModelAdmin): + list_display = ['name', 'url'] + list_filter = ['name'] + + +class KerberosSourceAdmin(admin.ModelAdmin): + list_display = ['name', 'url'] + list_filter = ['name'] + + +class AddressAdmin(admin.ModelAdmin): + list_display = ['street_number', 'street_name', 'zip_code', 'city', 'region', 'country'] + + +class PiiControllerAdmin(admin.ModelAdmin): + list_display = [ + 'public_key', + 'contact', + 'address', + 'email', + 'phone_number', + 'contact_uri', + 'privacy_policy_url', + ] + list_filter = ['contact'] + + +admin.site.register(models.OAuthSource, OAuthSourceAdmin) +admin.site.register(models.RestSource, RestSourceAdmin) +admin.site.register(models.KerberosSource, KerberosSourceAdmin) +admin.site.register(models.Address, AddressAdmin) +admin.site.register(models.PiiController, PiiControllerAdmin) diff --git a/manager/core/apps.py b/manager/core/apps.py new file mode 100644 index 0000000..26f78a8 --- /dev/null +++ b/manager/core/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + name = 'core' diff --git a/manager/core/exceptions.py b/manager/core/exceptions.py new file mode 100644 index 0000000..a2e32ea --- /dev/null +++ b/manager/core/exceptions.py @@ -0,0 +1,23 @@ +# pii manager - proof-of-concept implementation +# Copyright (C) 2021 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +class AuthzError(Exception): + pass + + +class PiiRetrievalError(Exception): + pass diff --git a/manager/core/forms.py b/manager/core/forms.py new file mode 100644 index 0000000..85791e1 --- /dev/null +++ b/manager/core/forms.py @@ -0,0 +1,15 @@ +# pii manager - proof-of-concept implementation +# Copyright (C) 2021 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . diff --git a/manager/core/migrations/0001_initial.py b/manager/core/migrations/0001_initial.py new file mode 100644 index 0000000..5598c10 --- /dev/null +++ b/manager/core/migrations/0001_initial.py @@ -0,0 +1,397 @@ +# Generated by Django 2.2.20 on 2021-04-29 13:40 + +import django.db.models.deletion +import phonenumber_field.modelfields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name='Address', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ('street_number', models.CharField(max_length=31, verbose_name='street number')), + ('street_name', models.CharField(max_length=255, verbose_name='street name')), + ('zip_code', models.CharField(max_length=15, verbose_name='zip code')), + ('city', models.CharField(max_length=127, verbose_name='city')), + ('region', models.CharField(max_length=127, verbose_name='region')), + ('country', models.CharField(max_length=127, verbose_name='country')), + ], + ), + migrations.CreateModel( + name='AuthorizationCode', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ('value', models.CharField(max_length=255, verbose_name='value of the authorization code')), + ('expires', models.DateTimeField(verbose_name='authorization code expiry timestamp')), + ], + ), + migrations.CreateModel( + name='KerberosSource', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ('name', models.CharField(max_length=127, verbose_name='source name')), + ( + 'source_type', + models.CharField( + choices=[ + ('oauth', 'OAuth'), + ('saml', 'SAML'), + ('rest', 'REST'), + ('kerberos', 'Kerberos'), + ], + default='oauth', + max_length=15, + verbose_name='source type', + ), + ), + ('url', models.URLField(unique=True, verbose_name='source url')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='OAuthServer', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ('name', models.CharField(max_length=127, verbose_name='provider name')), + ('url', models.URLField(unique=True, verbose_name='provider url')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='OAuthSource', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ('name', models.CharField(max_length=127, verbose_name='source name')), + ( + 'source_type', + models.CharField( + choices=[ + ('oauth', 'OAuth'), + ('saml', 'SAML'), + ('rest', 'REST'), + ('kerberos', 'Kerberos'), + ], + default='oauth', + max_length=15, + verbose_name='source type', + ), + ), + ('url', models.URLField(unique=True, verbose_name='source url')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='PiiCategory', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ( + 'name', + models.CharField(max_length=127, unique=True, verbose_name='target category of pii'), + ), + ('is_sensitive', models.BooleanField(default=False, verbose_name='sensitivity of pii')), + ], + ), + migrations.CreateModel( + name='Purpose', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ( + 'description', + models.CharField( + max_length=255, verbose_name='short description of the pii collection purpose' + ), + ), + ( + 'category', + models.CharField( + choices=[ + ('core function', 'core function'), + ('contracted service', 'contracted service'), + ('delivery', 'delivery'), + ('contact requested', 'contact requested'), + ('personalized experience', 'personalized experience'), + ('marketing', 'marketing'), + ], + default='core function', + max_length=127, + ), + ), + ], + ), + migrations.CreateModel( + name='RestSource', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ('name', models.CharField(max_length=127, verbose_name='source name')), + ( + 'source_type', + models.CharField( + choices=[ + ('oauth', 'OAuth'), + ('saml', 'SAML'), + ('rest', 'REST'), + ('kerberos', 'Kerberos'), + ], + default='oauth', + max_length=15, + verbose_name='source type', + ), + ), + ('url', models.URLField(unique=True, verbose_name='source url')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Service', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ('name', models.CharField(max_length=255, verbose_name='name of the service')), + ( + 'client_id', + models.CharField(max_length=255, verbose_name='client identifier of the service'), + ), + ( + 'client_secret', + models.CharField(max_length=255, verbose_name='client secret of the service'), + ), + ], + ), + migrations.CreateModel( + name='TerminationPolicy', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ( + 'description', + models.CharField(max_length=255, verbose_name='conditions for termination of consent'), + ), + ], + ), + migrations.CreateModel( + name='Token', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ('scopes', models.CharField(max_length=255, verbose_name='scopes for the access token')), + ('expires', models.DateTimeField(verbose_name='expiration timestamp')), + ('audience', models.CharField(max_length=255, verbose_name='audience of the token')), + ('not_before', models.DateTimeField(verbose_name='token not usable before')), + ( + 'code', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='core.AuthorizationCode', + verbose_name='access token', + ), + ), + ], + ), + migrations.CreateModel( + name='PiiController', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ( + 'public_key', + models.CharField(blank=True, max_length=8191, null=True, verbose_name='public key'), + ), + ( + 'contact', + models.CharField(max_length=255, verbose_name='contact name of the pii controller'), + ), + ( + 'email', + models.EmailField(max_length=254, verbose_name='pii controller contact email address'), + ), + ( + 'phone_number', + phonenumber_field.modelfields.PhoneNumberField( + max_length=128, region=None, verbose_name='pii controller contact phone number' + ), + ), + ( + 'contact_url', + models.URLField(blank=True, null=True, verbose_name='pii controller contact url'), + ), + ( + 'privacy_policy_url', + models.URLField(verbose_name="link to the pii controller's privacy statement"), + ), + ( + 'address', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='core.Address', + verbose_name='address of the pii controller', + ), + ), + ], + ), + migrations.CreateModel( + name='ConsentReceipt', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ('version', models.CharField(max_length=31, verbose_name='receipt version')), + ( + 'jurisdiction', + models.CharField(max_length=255, verbose_name='jurisdiction applying for the receipt'), + ), + ( + 'consent_timestamp', + models.DateTimeField(auto_now_add=True, verbose_name='consent timestamp'), + ), + ('collection_method', models.CharField(max_length=127, verbose_name='collection method')), + ( + 'receipt_id', + models.CharField(max_length=255, verbose_name='uuid4 identifier of the consent receipt'), + ), + ( + 'language', + models.CharField( + blank=True, + max_length=63, + null=True, + verbose_name='language in which the consent was obtained', + ), + ), + ( + 'pii_principal_id', + models.CharField(max_length=255, verbose_name='pii principal-provided identifier'), + ), + ( + 'on_behalf', + models.BooleanField( + default=False, + verbose_name='a pii processor acting on behalf of another processor or controller', + ), + ), + ( + 'consent_type', + models.CharField( + choices=[('implicit', 'implicit'), ('implicit', 'explicit')], + default='explicit', + max_length=15, + ), + ), + ( + 'third_party_disclosure', + models.BooleanField( + default=False, verbose_name='the pii controller is disclosing pii to a third party' + ), + ), + ( + 'third_party_name', + models.CharField( + max_length=255, + verbose_name='name or names of the third party to which the controller may disclose the pii', + ), + ), + ( + 'pii_categories', + models.ManyToManyField(related_name='consent_receipts', to='core.PiiCategory'), + ), + ( + 'pii_controller', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='core.PiiController', + verbose_name='fk to the first pii controller that collects the data', + ), + ), + ( + 'pii_controllers', + models.ManyToManyField( + blank=True, related_name='consent_receipts', to='core.PiiController' + ), + ), + ( + 'purpose', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to='core.Purpose', + verbose_name='purpose of collection', + ), + ), + ('purposes', models.ManyToManyField(related_name='consent_receipts', to='core.Purpose')), + ( + 'service', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='core.Service', + verbose_name='service or group of services for which PII is collected', + ), + ), + ('services', models.ManyToManyField(related_name='consent_receipts', to='core.Service')), + ( + 'termination_policy', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='core.TerminationPolicy', + verbose_name='termination policy', + ), + ), + ], + ), + migrations.AddField( + model_name='authorizationcode', + name='service', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='core.Service', + verbose_name='authorization code for the service', + ), + ), + ] diff --git a/manager/core/migrations/__init__.py b/manager/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manager/core/models.py b/manager/core/models.py new file mode 100644 index 0000000..d84b9df --- /dev/null +++ b/manager/core/models.py @@ -0,0 +1,293 @@ +# pii manager - proof-of-concept implementation +# Copyright (C) 2021 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.db import models +from django.utils.translation import ugettext_lazy as _ +from phonenumber_field import modelfields + +OAUTH = 'oauth' +SAML = 'saml' +REST = 'rest' +KERBEROS = 'kerberos' + +SOURCE_TYPES = [(OAUTH, _('OAuth')), (SAML, _('SAML')), (REST, _('REST')), (KERBEROS, _('Kerberos'))] + + +class GenericSource(models.Model): + name = models.CharField(max_length=127, null=False, blank=False, verbose_name=_('source name')) + source_type = models.CharField( + max_length=15, + null=False, + blank=False, + default=OAUTH, + choices=SOURCE_TYPES, + verbose_name=_('source type'), + ) + url = models.URLField(blank=False, null=False, unique=True, verbose_name=_('source url')) + + class Meta: + abstract = True + + +class GenericProvider(models.Model): + name = models.CharField(max_length=127, null=False, blank=False, verbose_name=_('provider name')) + url = models.URLField(blank=False, null=False, unique=True, verbose_name=_('provider url')) + + class Meta: + abstract = True + + +class OAuthSource(GenericSource): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.source_type = OAUTH + self.save() + + +class OAuthServer(GenericProvider): + pass + + +class RestSource(GenericSource): + def __init__(self, *args, **kwargs): + super().__init__(*arg, **kwargs) + self.source_type = REST + self.save() + + +class KerberosSource(GenericSource): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.source_type = KERBEROS + self.save() + + +class Address(models.Model): + street_number = models.CharField(max_length=31, blank=False, null=False, verbose_name=_('street number')) + street_name = models.CharField(max_length=255, blank=False, null=False, verbose_name=_('street name')) + zip_code = models.CharField(max_length=15, blank=False, null=False, verbose_name=_('zip code')) + city = models.CharField(max_length=127, blank=False, null=False, verbose_name=_('city')) + region = models.CharField(max_length=127, blank=False, null=False, verbose_name=_('region')) + country = models.CharField(max_length=127, blank=False, null=False, verbose_name=_('country')) + + +class PiiController(models.Model): + # xxx make it a many-to-one field to a pk model, see jwks for instance + public_key = models.CharField(max_length=8191, blank=True, null=True, verbose_name=_('public key')) + contact = models.CharField( + max_length=255, blank=False, null=False, verbose_name=_('contact name of the pii controller') + ) + address = models.ForeignKey( + on_delete=models.CASCADE, to=Address, verbose_name=_('address of the pii controller') + ) + email = models.EmailField(blank=False, null=False, verbose_name=_('pii controller contact email address')) + phone_number = modelfields.PhoneNumberField( + blank=False, null=False, verbose_name=_('pii controller contact phone number') + ) + contact_url = models.URLField(blank=True, null=True, verbose_name=_('pii controller contact url')) + privacy_policy_url = models.URLField( + blank=False, null=False, verbose_name=_('link to the pii controller\'s privacy statement') + ) + + +class Service(models.Model): + name = models.CharField(max_length=255, blank=False, null=False, verbose_name=_('name of the service')) + client_id = models.CharField( + max_length=255, blank=False, null=False, verbose_name=_('client identifier of the service') + ) + client_secret = models.CharField( + max_length=255, blank=False, null=False, verbose_name=_('client secret of the service') + ) + + @classmethod + def create(cls, name): + instance = cls.objects.create(name=name) + # todo generate id/secret pair on client initialization + return instance + + +class AuthorizationCode(models.Model): + service = models.ForeignKey( + to=Service, verbose_name=_('authorization code for the service'), on_delete=models.CASCADE + ) + value = models.CharField( + max_length=255, blank=False, null=False, verbose_name=_('value of the authorization code') + ) + expires = models.DateTimeField( + blank=False, null=False, verbose_name=_('authorization code expiry timestamp') + ) # todo default according to lifetime policy + + def generate_token(self): + # todo create token fields, object initialization + pass + + +class Token(models.Model): + code = models.ForeignKey(to=AuthorizationCode, verbose_name=_('access token'), on_delete=models.CASCADE) + scopes = models.CharField(max_length=255, verbose_name=_('scopes for the access token')) + expires = models.DateTimeField(blank=False, null=False, verbose_name=_('expiration timestamp')) + audience = models.CharField(max_length=255, verbose_name=_('audience of the token')) + not_before = models.DateTimeField(blank=False, null=False, verbose_name=_('token not usable before')) + + def serialize(self): + pass + + @classmethod + def deserialize(cls, value): + pass + + +class Purpose(models.Model): + # for comprehensive list, see https://kantarainitiative.org/confluence/x/74K-BQ + CAT_CORE_FUNCTION = 'core function' + CAT_CONTRACTED_SERVICE = 'contracted service' + CAT_DELIVERY = 'delivery' + CAT_CONTACT_REQUESTED = 'contact requested' + CAT_PERSONALIZED_EXPERIENCE = 'personalized experience' + CAT_MARKETING = 'marketing' + + CATEGORY_CHOICES = [ + (CAT_CORE_FUNCTION, _('core function')), + (CAT_CONTRACTED_SERVICE, _('contracted service')), + (CAT_DELIVERY, _('delivery')), + (CAT_CONTACT_REQUESTED, _('contact requested')), + (CAT_PERSONALIZED_EXPERIENCE, _('personalized experience')), + (CAT_MARKETING, _('marketing')), + ] + + description = models.CharField( + max_length=255, + blank=False, + null=False, + verbose_name=_('short description of the pii collection purpose'), + ) + category = models.CharField( + max_length=127, blank=False, null=False, default=CAT_CORE_FUNCTION, choices=CATEGORY_CHOICES + ) + + +class PiiCategory(models.Model): + name = models.CharField( + max_length=127, blank=False, null=False, unique=True, verbose_name=_('target category of pii') + ) + is_sensitive = models.BooleanField(default=False, verbose_name=_('sensitivity of pii')) + + +class TerminationPolicy(models.Model): + # xxx define clear categories + description = models.CharField( + max_length=255, blank=False, null=False, verbose_name=_('conditions for termination of consent') + ) + + +class ConsentReceipt(models.Model): + TYPE_IMPLICIT = 'implicit' + TYPE_EXPLICIT = 'explicit' + + TYPE_CHOICES = [(TYPE_IMPLICIT, _('implicit')), (TYPE_IMPLICIT, _('explicit'))] + + ''' + Mandatory consent receipt transaction fields + ''' + version = models.CharField(max_length=31, blank=False, null=False, verbose_name=_('receipt version')) + jurisdiction = models.CharField( + max_length=255, blank=False, null=False, verbose_name=_('jurisdiction applying for the receipt') + ) + consent_timestamp = models.DateTimeField( + auto_now_add=True, blank=False, null=False, verbose_name=_('consent timestamp') + ) + # xxx which choices for the collection method? + collection_method = models.CharField( + max_length=127, blank=False, null=False, verbose_name=_('collection method') + ) + # xxx uuid 4 default generator + receipt_id = models.CharField( + max_length=255, + blank=False, + null=False, + # default=utils.new_uuid4, # todo + verbose_name=('uuid4 identifier of the consent receipt'), + ) + + ''' + Optional consent receipt transaction fields + ''' + # xxx iso 639 choices and validator + language = models.CharField( + max_length=63, blank=True, null=True, verbose_name=_('language in which the consent was obtained') + ) + + ''' + Mandatory consent transaction parties fields + ''' + pii_principal_id = models.CharField( + max_length=255, blank=False, null=False, verbose_name=_('pii principal-provided identifier') + ) + pii_controllers = models.ManyToManyField(to=PiiController, blank=True, related_name='consent_receipts') + pii_controller = models.ForeignKey( + to=PiiController, + verbose_name=_('fk to the first pii controller that collects the data'), + on_delete=models.CASCADE, + ) + + ''' + Optional consent transaction parties fields + ''' + on_behalf = models.BooleanField( + default=False, verbose_name=_('a pii processor acting on behalf of another processor or controller') + ) + + ''' + Data, collection and use fields + ''' + services = models.ManyToManyField(to=Service, blank=False, related_name='consent_receipts') + service = models.ForeignKey( + to=Service, + verbose_name=_('service or group of services for which PII is collected'), + on_delete=models.CASCADE, + ) + purposes = models.ManyToManyField(to=Purpose, blank=False, related_name='consent_receipts') + purpose = models.ForeignKey( + to=Purpose, null=True, blank=True, verbose_name=_('purpose of collection'), on_delete=models.CASCADE + ) + consent_type = models.CharField( + max_length=15, blank=False, null=False, default=TYPE_EXPLICIT, choices=TYPE_CHOICES + ) + pii_categories = models.ManyToManyField(to=PiiCategory, blank=False, related_name='consent_receipts') + termination_policy = models.ForeignKey( + to=TerminationPolicy, + blank=False, + null=False, + verbose_name=_('termination policy'), + on_delete=models.CASCADE, + ) + third_party_disclosure = models.BooleanField( + default=False, verbose_name=_('the pii controller is disclosing pii to a third party') + ) + third_party_name = models.CharField( + max_length=255, + blank=False, + null=False, + verbose_name=_('name or names of the third party to which the controller may disclose the pii'), + ) + + @property + def sensitive_pii_categories(self): + return PiiCategory.objects.filter(consent_receipts__id=self.id, sensitivity=True) + + @property + def sensitive_pii(self): + return bool(PiiCategory.objects.filter(consent_receipts__id=self.id, sensitivity=True).count()) diff --git a/manager/core/source_backend.py b/manager/core/source_backend.py new file mode 100644 index 0000000..3833f6a --- /dev/null +++ b/manager/core/source_backend.py @@ -0,0 +1,85 @@ +# pii manager - proof-of-concept implementation +# Copyright (C) 2021 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +class BaseBackend(object): + ''' + Base class for the PII Manager's Source Backend + ''' + + interactive = False + + def __init__(self, source, *args, **kwargs): + self.source = source + + def get_resource(self, *args, **kwargs): + pass + + +class OAuthBackend(BaseBackend): + ''' + Minimalistic OAuth 2.0 client for the PII Manager's Source Backend + Supported grant types are: + - authorization code (https://tools.ietf.org/html/rfc6749#section-4.1) + - implicit (https://tools.ietf.org/html/rfc6749#section-4.2) + ''' + + # user interactions are required + interactive = True + + def __init__(self, source, *args, **kwargs): + super().__init__(source, *args, **kwargs) + self.get_authorization() + + def get_authorization(self): + # required in authorization code grant only + pass + + def get_token(self, *args, **kwargs): + pass + + +class RestBackend(BaseBackend): + ''' + REST support class for the PII Manager's Source Backend + ''' + + def __init__(self, source, *args, **kwargs): + super().__init__(source, *args, **kwargs) + self.authenticate() + + def authenticate(self, *args, **kwargs): + pass + + def get_credentials(self, *args, **kwargs): + pass + + +class SamlBackend(BaseBackend): + ''' + TODO Not supported yet + ''' + + # user interactions are required, no backchannel pii retrieval + interactive = True + + +class KerberosBackend(BaseBackend): + ''' + TODO Not supported yet + ''' + + pass diff --git a/manager/core/templates/manager/core/authorize.html b/manager/core/templates/manager/core/authorize.html new file mode 100644 index 0000000..bb9fea6 --- /dev/null +++ b/manager/core/templates/manager/core/authorize.html @@ -0,0 +1,22 @@ +{% extends "manager/base.html" %} +{% load i18n %} + +{% block content %} +
+ {% block form-intro %} + {% blocktrans %} +

The service {{ service }} is requesting access to scopes {{ scopes }} on your personal information.

+ {% endblocktrans %} + {% endblock %} + {% block form %} +
+ {% csrf_token %} + {{ form.as_p }} +
+ + +
+
+ {% endblock %} +
+{% endblock %} diff --git a/manager/core/tests.py b/manager/core/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/manager/core/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/manager/core/urls.py b/manager/core/urls.py new file mode 100644 index 0000000..8c0b206 --- /dev/null +++ b/manager/core/urls.py @@ -0,0 +1,28 @@ +# pii manager - proof-of-concept implementation +# Copyright (C) 2021 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.conf.urls import include, url +from django.contrib import admin + +from manager.core import views + +# from manager.core.api_views import router # todo + +urlpatterns = [ + url(r'^resource/(?P[A-Za-z0-9_ -]+)/$', views.resource, name='resource_endpoint'), + # url(r'^api/', include(router.urls)), # todo + # url(r'^api/', include('manager.core.api_urls')), # todo +] diff --git a/manager/core/utils.py b/manager/core/utils.py new file mode 100644 index 0000000..d2fe14f --- /dev/null +++ b/manager/core/utils.py @@ -0,0 +1,40 @@ +# pii manager - proof-of-concept implementation +# Copyright (C) 2021 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import uuid + +from manager.core.exceptions import AuthzError +from manager.core.models import AuthorizationCode, Token + + +def generate_uuid(value=None): + return str(uuid.uuid4()) + + +def is_token_valid(authz_code, token): + try: + if not isinstance(authz_code, AuthorizationCode): + raise AuthzError('code must be an AuthorizationCode object') + if not isinstance(token, (str, Token)): + raise AuthzError('token must either be str or Token object') + if isinstance(token, str): + token = Token.deserialize(token) + # todo check scopes + # todo check audience + # todo check expiry and not_before + # todo check code + except AuthzError: + return False diff --git a/manager/core/views.py b/manager/core/views.py new file mode 100644 index 0000000..619bf09 --- /dev/null +++ b/manager/core/views.py @@ -0,0 +1,186 @@ +# pii manager - proof-of-concept implementation +# Copyright (C) 2021 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.core.exceptions import MultipleObjectsReturned +from django.http import HttpResponseBadRequest, HttpResponseRedirect +from django.shortcuts import render +from django.utils.encoding import force_text +from django.views.generic import FormView, TemplateView, View +from jwcrypto.common import JWException +from jwcrypto.jwk import JWK +from jwcrypto.jwt import JWT +from rest_framework.response import Response +from rest_framework.views import APIView + +from manager.core.models import AuthorizationCode, GenericProvider, GenericSource + + +class ResourceView(APIView): + def get(self, request, *args, **kwargs): + resource = {} + # 1. retrieve access token + token = request.GET.get('token', '') + header, payload, sig, *_ = token.split('.') + # 2. local oken verification + try: + token = JWT() # todo + token.verify(sig) + except JWException as e: + return Response(request, {'err': 'invalid_token'}, status=403) + + try: + source = GenericProvider.objects.get(url=JWT.fields.provider_uri) + except GenericProvider.DoesNotExist as e: + return Response(request, {'err': 'unknown_provider'}, status=403) + except MultipleObjectsReturned as e: + return Response(request, {'err': 'invalid_provider'}, status=403) + + resource_id = request.GET.get('resource_id') + user_id = request.GET.get('pii_principal_id') + if not resource_id: + return Response(request, {'err': 'unknown_resource'}, status=404) + if not user_id: + return Response(request, {'err': 'invalid_pii_principal_id'}, status=403) + consent_receipts = ConsentReceipt.objects.filter(pii_identifier=resource_id, pii_principal_id=user_id) + requested_scopes = request.GET.get('scopes', '').split() + + if not consent_receipts: + # authorization required, user interaction needed + # xxx not a redirect, fail and mention authorization url + return Response( + request, + {'err': 'user_authorization_required'}, + url=authorization_url, + scopes=requested_scopes, + status=403, + ) + + # if not request.GET.get('user_id') -> error + previously_granted_scopes = [] + for receipt in consent_receipts: + previously_granted_scopes.append(consent_receipts.scopes_set.split()) + + # 3. online verification? + if is_online_verifiable(service): + is_verified = perform_online_verification(token, service) + if not is_verified: + return Response( + request, + {'err': 'invalid_token'}, # xxx mention that online verification has been performed + status=403, + ) + + if diff_scopes := set(requested_scopes) - set(previously_granted_scopes): + # todo broken here + # uma logic and scope reduction algo + # 3.a. Scope translation + # We need to define a new mapping object, preferably model + # 3.b. Set inclusion in service's authorized scopes in stored CRs + # Check if compatible with our current CR model + # 3.c. Perform No/maybe decision + if not perform_uma_decision(service): + return redirect(request, authorization_url, scopes=diff_scopes) + else: + requested_scopes = m + # 4. reverse scope reduction + # 4.a. retrieve source(s) hosting pii + # 4.b. call each target source's backend + # 4.c. return available pii to the caller + + return Response( + { + 'err': 0, + 'data': resources, + } + ) + + +resource = ResourceView.as_view() + + +def AuthorizeView(TemplateView): + template = 'manager/core/authorize.html' + success_url = '/' + + def redirect(self, **kwargs): + return HttpResponseRedirect(make_url(self.redirect_uri, **kwargs)) + + def dispatch(self, request): + # mandatory request parameters + self.redirect_uri = request.GET.get('redirect_uri') + if not self.redirect_uri: + return HttpResponseBadRequest('missing redirect_uri parameter') + + client_id = request.GET.get('client_id') + if not client_id: + return HttpResponseBadRequest('missing client_id parameter') + + response_type = request.GET.get('response_type') + if not response_type: + return HttpResponseBadRequest('missing response_type parameter') + + try: + self.service = Service.objects.get(client_id=client_id) + # todo check redirect uri against client's uris + except Service.DoesNotExist as e: + return self.redirect(error='unauthorized_client') + self.state = request.GET.get('state') + + return super().dispatch(request) + + def form_valid(self, request): + scopes = request.POST.get('scopes') + # xxx required claims from scopes? + # 1. create consent receipt + # 2. generate authorization code + return HttpResponseRedirect(self.redirect_uri, code=authorization_code, state=self.state) + + def post(self, request): + if 'cancel' in request.POST: + return self.redirect(error='access_denied') + return super().post(request) + + # todo get_form_kwargs, user details? + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['service'] = self.service + # todo display scopes? + return ctx + + +authorize = ResourceView.as_view() + + +class TokenView(APIView): + def post(self, request, *args, **kwarsg): + if request.data['grant_type'] != 'authorization_code': # todo uma grant + return Response({'error': 'unsupported_grant_type'}, status=400) + + # validate authorization code + try: + authorization_code = AuthorizationCode.objects.get(value=request.data['code']) + except AuthorizationCode.DoesNotExist: + return Response({'error': 'invalid_grant'}, status=400) + + # todo check token lifetime + + # issue access token + token = authorization_code.generate_token() + return Response({'token': force_text(token)}) # todo 'expires' param + + +token = TokenView.as_view() diff --git a/manager/settings.py b/manager/settings.py new file mode 100644 index 0000000..418d64f --- /dev/null +++ b/manager/settings.py @@ -0,0 +1,138 @@ +""" +Django settings for manager project. + +Generated by 'django-admin startproject' using Django 1.11.18. + +For more information on this file, see +https://docs.djangoproject.com/en/1.11/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.11/ref/settings/ +""" + +import os + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = '*v%=be_ic=5xivp#63l#ii%ax0%fvty_q=an0app#9z^1(zv9%' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + +# Hey Entr'ouvert is in France !! +TIME_ZONE = 'Europe/Paris' +LANGUAGE_CODE = 'fr' +USE_L10N = True +USE_TZ = True + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'mellon', + 'manager', + 'manager.core', + 'rest_framework', + 'xstatic.pkg.jquery', + 'xstatic.pkg.jquery_ui', + 'xstatic.pkg.select2', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.locale.LocaleMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +LOCALE_PATHS = (os.path.join(BASE_DIR, 'locale'),) + +ROOT_URLCONF = 'manager.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.i18n', + 'django.template.context_processors.media', + 'django.template.context_processors.request', + 'django.template.context_processors.static', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'manager.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/1.11/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/1.11/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.11/howto/static-files/ + +STATIC_URL = '/static/' diff --git a/manager/templates/manager/base.html b/manager/templates/manager/base.html new file mode 100644 index 0000000..f1bf422 --- /dev/null +++ b/manager/templates/manager/base.html @@ -0,0 +1,9 @@ +{% extends "manager/root.html" %} +{% load i18n static %} +{% block page-title %}PII Manager{% endblock %} +{% block site-title %}PII Manager{% endblock %} + +{% block logout-url %}{% url "auth_logout" %}{% endblock %} + +{% block content %} +{% endblock %} diff --git a/manager/templates/manager/root.html b/manager/templates/manager/root.html new file mode 100644 index 0000000..056ca32 --- /dev/null +++ b/manager/templates/manager/root.html @@ -0,0 +1,102 @@ +{% load i18n staticfiles %} + + + + {% block page-title %}{% endblock %} + + + + + + {{ media }} + {% block extrascripts %} + {% endblock %} + + +
+ {% block sidepage %} + {% endblock %} + {% block user-links %} + + {% endblock %} +
+ +
+ +
+ {% block main-content %} + +
+ +
+ {% block appbar %} + {% endblock %} +
+ +{% block messages %} +{% if messages %} +
    + {% for message in messages %} + {{ message }} + {% endfor %} +
+{% endif %} +{% endblock %} + + {% block beforecontent %} + {% endblock %} + + {% block content %} + {% endblock %} +
+ + {% block aftercontent %} + {% endblock %} + +
+ {% endblock %} +
+ + {% block after-main-content %} + {% endblock %} + + {% block sidebar %} + {% endblock %} + +
+ + + + {% block page-end %} + {% endblock %} + + diff --git a/manager/tests/test_core.py b/manager/tests/test_core.py new file mode 100644 index 0000000..2047dbe --- /dev/null +++ b/manager/tests/test_core.py @@ -0,0 +1,59 @@ +import pytest +from httmock import HTTMock, urlmatch + +from manager.core.models import ( + ConsentReceipt, + OAuthServer, + OAuthSource, + PiiCategory, + PiiController, + Purpose, + Service, +) + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def oauth_source(): + # todo mock responses + return OAuthSource.object.create(name='test oauth source', url='https://www.myoauthsource.org/resources/') + + +def oauth_source_mock(oauth_source): + # @urlmatch todo + def resource_endpoint_mock(): + pass + + return HTTMock() # todo + + +@pytest.fixture +def oauth_server(): + # todo mock responses + return OAuthServer.objects.create( + name='test oauth server', url='https://www.myoauthserver.org/authorize/' + ) + + +def oauth_server_mock(oauth_server): + # @urlmatch todo + def authz_endpoint_mock(): + pass + + # @urlmatch todo + def token_endpoint_mock(): + pass + + return HTTMock() # todo + + +@pytest.fixture +def oauth_urm_client(): + pass + + +def test_simple_oauth(oauth_source, oauth_server): + with oauth_source_mock(oauth_source): + with oauth_server_mock(oauth_server): + pass diff --git a/manager/urls.py b/manager/urls.py new file mode 100644 index 0000000..16668a2 --- /dev/null +++ b/manager/urls.py @@ -0,0 +1,40 @@ +# pii manager - proof-of-concept implementation +# Copyright (C) 2021 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.conf import settings +from django.conf.urls import include, url +from django.contrib import admin + +from manager import views +from manager.core import urls as core_urls + +urlpatterns = [ + url(r'^admin/', admin.site.urls), + url(r'^login/$', views.login, name='auth_login'), + url(r'^logout/$', views.logout, name='auth_logout'), + url(r'^core/', include(core_urls)), +] + +if settings.DEBUG and 'debug_toolbar' in settings.INSTALLED_APPS: + import debug_toolbar + + urlpatterns = [ + url(r'^__debug__/', include(debug_toolbar.urls)), + ] + urlpatterns + + +if 'mellon' in settings.INSTALLED_APPS: + urlpatterns.append(url(r'^accounts/mellon/', include('mellon.urls'))) diff --git a/manager/views.py b/manager/views.py new file mode 100644 index 0000000..3d5009d --- /dev/null +++ b/manager/views.py @@ -0,0 +1,45 @@ +# pii manager - proof-of-concept implementation +# Copyright (C) 2021 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.contrib.auth import views as auth_views +from django.http import HttpResponseRedirect +from django.utils.decorators import method_decorator +from django.views.decorators.cache import never_cache + + +class LoginView(auth_views.LoginView): + def get(self, request, *args, **kwargs): + if any(get_idps()): + if not 'next' in request.GET: + return HttpResponseRedirect(resolve_url('mellon_login')) + return HttpResponseRedirect( + resolve_url('mellon_login') + '?next=' + quote(request.GET.get('next')) + ) + return super(LoginView, self).get(request, *args, **kwargs) + + +login = LoginView.as_view() + + +class LogoutView(auth_views.LogoutView): + @method_decorator(never_cache) + def dispatch(self, request, *args, **kwargs): + if any(get_idps()): + return HttpResponseRedirect(resolve_url('mellon_logout')) + return super(LogoutView, self).dispatch(request, *args, **kwargs) + + +logout = LogoutView.as_view() diff --git a/manager/wsgi.py b/manager/wsgi.py new file mode 100644 index 0000000..b3b435d --- /dev/null +++ b/manager/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for manager project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "manager.settings") + +application = get_wsgi_application() diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..95a9f25 --- /dev/null +++ b/readme.md @@ -0,0 +1,31 @@ +h1. Quickstart: + +On Debian stable/testing, install within a python3 virtual environment: + +
+sudo apt install virtualenv python3-xstatic
+mkdir ~/pii-manager-venv/
+virtualenv -p python3 ~/pii-manager-venv/
+source ~/pii-manager-venv/bin/activate
+python3 ./setup.py develop
+
+ +h1. Local testing: + +
+python3 ./manage.py makemigrations
+python3 ./manage.py migrate
+python3 ./manage.py runserver 127.0.0.1:8080
+
+ +h1. Todolist: + +* PostgreSQL integration +* uwsgi/nginx configuration +* gadjo support +* crud api views on main models + +h1. Optional todos: + +* hobo integration (?) -- see https://git.entrouvert.org/hobo.git/ +* publik devinst integration (?) -- see https://git.entrouvert.org/publik-devinst.git/ diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..74a84b8 --- /dev/null +++ b/setup.py @@ -0,0 +1,93 @@ +#! /usr/bin/env python +# +''' + Setup script for PII Manager proof-of-concept implementations +''' + +import os +import subprocess + +from setuptools import find_packages, setup + + +def get_version(): + """Use the VERSION, if absent generates a version with git describe, if not + tag exists, take 0.0- and add the length of the commit log. + """ + if os.path.exists('VERSION'): + with open('VERSION', 'r') as v: + return v.read() + if os.path.exists('.git'): + p = subprocess.Popen( + ['git', 'describe', '--dirty=.dirty', '--match=v*'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + result = p.communicate()[0] + if p.returncode == 0: + result = result.decode('ascii').strip()[1:] # strip spaces/newlines and initial v + if '-' in result: # not a tagged version + real_number, commit_count, commit_hash = result.split('-', 2) + version = '%s.post%s+%s' % (real_number, commit_count, commit_hash) + else: + version = result + return version + else: + return '0.0.post%s' % len(subprocess.check_output(['git', 'rev-list', 'HEAD']).splitlines()) + return '0.0' + + +setup( + name="pii-manager-poc", + version=get_version(), + license="AGPLv3+", + description="PII Manager proof-of-concept implementation", + url="http://git.entrouvert.org/pii-manager-poc.git/", + author="Entr'ouvert", + author_email="pmarillonnet@entrouvert.com", + maintainer="Paul Marillonnet", + maintainer_email="pmarillonnet@entrouvert.com", + scripts=('manage.py',), + packages=find_packages('manager'), + package_dir={ + '': 'manager', + }, + include_package_data=True, + install_requires=[ + 'django>=1.11,<2.3', + 'requests>=2.3', + 'requests-oauthlib', + 'Django-Select2>5,<6', + 'django-ratelimit', + 'djangorestframework>=3.3,<3.10', + 'pycryptodomex', + 'django-mellon>=1.22', + 'jwcrypto>=0.3.1,<1', + 'cryptography', + 'XStatic-jQuery<2', + 'XStatic-jquery-ui', + 'xstatic-select2', + 'pytz', + 'sqlparse', + 'django-phonenumber-field', + 'phonenumbers', + ], + zip_safe=False, + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Framework :: Django", + 'Intended Audience :: End Users/Desktop', + 'Intended Audience :: Developers', + 'Intended Audience :: System Administrators', + 'Intended Audience :: Information Technology', + 'Intended Audience :: Legal Industry', + 'Intended Audience :: Science/Research', + 'Intended Audience :: Telecommunications Industry', + "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Topic :: System :: Systems Administration :: Authentication/Directory", + ], + cmdclass={}, +)