initial commit, draft support for oauth and rest sources

This commit is contained in:
Paul Marillonnet 2021-04-21 10:51:48 +02:00
commit a0391b2bdd
28 changed files with 2018 additions and 0 deletions

5
code-style.sh Executable file
View File

@ -0,0 +1,5 @@
#!/bin/bash
black --target-version py37 --skip-string-normalization --line-length 110 .
isort --profile black --line-length 110 .

22
getlasso3.sh Executable file
View File

@ -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

View File

@ -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 <pmarillonnet@entrouvert.com>, 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 <pmarillonnet@entrouvert.com>\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 dautorisation pour le service"
#: manager/core/models.py:203
msgid "value of the authorization code"
msgstr "valeur du code dautorisation"
#: manager/core/models.py:207
msgid "authorization code expiry timestamp"
msgstr "horodate dexpiration du code dautorisation"
#: manager/core/models.py:217
msgid "access token"
msgstr "jeton daccès"
#: manager/core/models.py:221
msgid "scopes for the access token"
msgstr "portées du jeton daccès"
#: manager/core/models.py:225
msgid "expiration timestamp"
msgstr "horodate dexpiration"
#: 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 dapplication 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 dun 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"
" <p>The service %(service)s is requesting access to scopes %(scopes)s "
"on your personal information.</p>\n"
" "
msgstr ""
"\n"
" <p> Le service %(service)s demande laccès aux portées %(scopes)s "
"concernant vos données personnelles.</p>\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 daccueil"

22
manage.py Executable file
View File

@ -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)

0
manager/__init__.py Normal file
View File

0
manager/core/__init__.py Normal file
View File

58
manager/core/admin.py Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
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)

5
manager/core/apps.py Normal file
View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
name = 'core'

View File

@ -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 <http://www.gnu.org/licenses/>.
class AuthzError(Exception):
pass
class PiiRetrievalError(Exception):
pass

15
manager/core/forms.py Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.

View File

@ -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',
),
),
]

View File

293
manager/core/models.py Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
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())

View File

@ -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 <http://www.gnu.org/licenses/>.
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

View File

@ -0,0 +1,22 @@
{% extends "manager/base.html" %}
{% load i18n %}
{% block content %}
<div id="manager-core-authorize">
{% block form-intro %}
{% blocktrans %}
<p>The service {{ service }} is requesting access to scopes {{ scopes }} on your personal information.</p>
{% endblocktrans %}
{% endblock %}
{% block form %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<div class="buttons">
<button name="submit">{% trans "Choose" %}</button>
<button name="cancel">{% trans "Cancel" %}</button>
</div>
</form>
{% endblock %}
</div>
{% endblock %}

3
manager/core/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

28
manager/core/urls.py Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
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<resource_id>[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
]

40
manager/core/utils.py Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
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

186
manager/core/views.py Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
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()

138
manager/settings.py Normal file
View File

@ -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/'

View File

@ -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 %}

View File

@ -0,0 +1,102 @@
{% load i18n staticfiles %}<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>{% block page-title %}{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon">
<script src="{% xstatic 'jquery' 'jquery.min.js' %}"></script>
<script src="{% xstatic 'jquery-ui' 'jquery-ui.min.js' %}"></script>
{{ media }}
{% block extrascripts %}
{% endblock %}
</head>
<body {% block bodyargs %}{% endblock %}>
<div id="top">
{% block sidepage %}
{% endblock %}
{% block user-links %}
<ul class="user-info">
{% if global_title %}
<li class="ui-platform-name">{% if portal_url %}<a href="{{portal_url}}">{{ global_title }}</a>{% else %}{{ global_title }}{% endif %}</li>
{% endif %}
{% if user.is_authenticated %}
<li class="ui-avatar">{{ user.get_full_name|slice:":1" }}</li>
<li class="ui-name">{% block user-name %}{{ user.get_full_name }}{% endblock %}</li>
<li class="ui-logout"><a href="{% block logout-url %}index.html{% endblock %}"
title="{% trans "Logout" %}"></a></li>
{% endif %}
{% block help-link %}
{% endblock %}
</ul>
{% endblock %}
</div>
<div id="header">
{% block site-header %}
<h1>{% block site-title %}{% endblock %}</h1>
{% block subheader %}{% endblock %}
{% endblock %}
</div>
<div id="main">
<div id="main-content" {% block main-content-attributes %}{% endblock %}>
{% block main-content %}
<div id="more-user-links">
{% block more-user-links %}
<span id="breadcrumb">
{% block breadcrumb %}
<a href="{% block homepage-url %}/{% endblock %}">{% block homepage-title %}{% trans "Homepage" %}{% endblock %}</a>
{% endblock %}
</span>
{% endblock %}
</div>
<div id="content">
<div id="appbar">
{% block appbar %}
{% endblock %}
</div>
{% block messages %}
{% if messages %}
<ul class="messages">
{% for message in messages %}
<li{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}
{% block beforecontent %}
{% endblock %}
{% block content %}
{% endblock %}
<br style="clear: both;"/>
{% block aftercontent %}
{% endblock %}
</div> <!-- #content -->
{% endblock %}
</div> <!-- #main-content -->
{% block after-main-content %}
{% endblock %}
{% block sidebar %}
{% endblock %}
</div> <!-- #main -->
<div id="footer">
{% block footer %}
Copyright &copy; 2021 Entr'ouvert
{% endblock %}
</div>
{% block page-end %}
{% endblock %}
</body>
</html>

View File

@ -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

40
manager/urls.py Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
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')))

45
manager/views.py Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
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()

16
manager/wsgi.py Normal file
View File

@ -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()

31
readme.md Normal file
View File

@ -0,0 +1,31 @@
h1. Quickstart:
On Debian stable/testing, install within a python3 virtual environment:
<pre>
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
</pre>
h1. Local testing:
<pre>
python3 ./manage.py makemigrations
python3 ./manage.py migrate
python3 ./manage.py runserver 127.0.0.1:8080
</pre>
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/

93
setup.py Executable file
View File

@ -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={},
)