Aug 25, 2013

Un premier module pour boilerplate III

Fin des vacances, la suite des précédents articles s'attaque à la mise en place du mécanisme d'ACL a proprement parler.

Disclaimer

Cet article peut contenir des propos choquant pour les jeunes enfants mais surtout il ne traite pas de l'authentification des utilisateurs qui est un problème complexe et bien plus général pour n'être abordé qu'au cours d'un petit tutorial comme celui-ci.

C'était bien le Verdon

Avant toute chose, petit retour sur les articles précédents suite à une discussion avec un ami qui utilise boilerplate. Le sujet était le suivant : "pourquoi il n'y a pas de méthode de désérialisation JSON dans les modèles".

L'application peut être découpée en zones fonctionnelles, la zone serveur qui travaille avec des objets Python et la zone web qui travaille avec des objets JSON. Le lien entre ces zones se fait à travers le wrapper REST, c'est donc ici que se fait tout le travail d'adaptation du JSON vers Python. Cela n'appartient pas au modèle. Dans son utilisation par défaut, le wrapper utilisera l'objet JSON pour fixer les paramètres d'appels des méthodes de l'API associée et ne fait rien de plus. Les dict imbriqués sont transmis tel quel. Il s'agit donc à la méthode transform() de les transformer en instances d'un type donné si cela est nécessaire.

La sérialisation JSON ne reste elle qu'une représentation d'un objet, tout comme la méthode __unicode__() en est une. Cette fonctionnalité à donc toute sa place dans le modèle qu'elle cherchera à représenter.

En plus il faisait beau

Ceci étant dit, reprenons là où nous nous étions arrêtés. Le dernier article a permis la mise en place d'un système permettant d'enregistrer des rôles ainsi que des utilisateurs (disposant d'une liste de rôles associés). L'idée est d'utiliser cette base d'utilisateurs pour définir les droits d'accès aux fonctionnalités de l'application exposée sur le "web" en fonction de l'utilisateur qui se présente.

Pour cela, boilerplate fournit un helper nommé AutoAuth. Ce dernier s'appuie sur Flask-Principal qui a pour but d'être le pivot central d'un système de gestion des droits en associant le service d'authentification à un fournisseur d'identités. Le rôle d'AutoAuth est de mettre en place une instance de Flask-Principal, les handlers nécessaires, etc. dans l'application.

AutoAuth s'instancie avec deux paramètres permettant de définir le service d'authentification et le fournisseur d'identités.

Le premier, auth_provider, est une fonction qui sera appelée avant le traitement de chaque requête HTTP. Cette fonction doit appeler la méthode identity_changed.send() de Flask-Principal pour envoyer un objet Identity.

Le second, info_provider, est un objet disposant d'une méthode get qui reçoit un identifiant en paramètre et retourne un objet avec les propriétés (optionnelles) id, roles et name qui complèteront l'objet Identity créé lors de l'authentification. Si le provider retourne None ou tout simplement s'il n'est pas défini, tous les utilisateurs seront du type AnonymousIdentity.

Comme dit dans l'avertissement, cet article ne porte pas sur les méthodes d'authentification, aussi pour les besoins du tutoriel, nous utiliserons bêtement et méchament le provider header_auth fournit dans boilerplate. Un rapide coup d'oeil au [code source][1] vous éclairera sur son fonctionnement.

Qui suis-je ?

Le service d'authentification n'étant pas le point d'intérêt de l'article, c'est le fournisseur d'identités qui nous intéresse. Il nous faut donc une interface nous permettant de retrouver un User sur la base de son identifiant via une méthode get(). Il existe déjà une API nous permettant de récupérer ces objets, nommée UserAPI et définie dans l'article précédent.

Nous allons donc ajouter une méthode get() à cette classe :

modules/admin/api/user.py :

class UserAPI(CrudAPI):
    """The API to manipulate an user."""

    def __init__(self):
        super(UserAPI, self).__init__(User, 'username')
        pass

    def get(self, username):

        # sanity check
        if username is None:
            return None

        user = self.retrieve(username)
        if user is not None and user.active:
            return user
        return None

Cette méthode est très simple, elle récupère l'utilisateur grace à la méthode retrieve déjà présente et si l'utilisateur existe, alors elle vérifie s'il est actif. Pour tous les autres cas, elle return None. On notera le test if username is None, cela est une simple protection pour éviter un effet de bord, en effet retrieve appelé avec None retourne l'ensemble des utilisateurs. Bien que cela ne présente aucun risque, le comportement est précisé.

L'étape suivante consiste à intégrer AutoAuth à l'application. À la racine du projet dans _init_.py, il faut importer AutoAuth et l'instancier puis l'initialiser dans la méthode create_app()

init.py

...
from boilerplate.helpers.auth import AutoAuth
from boilerplate.helpers.auth.providers import header_auth
...

...
db = SQLAlchemy()
...
# l'import se trouve à cette position en raison d'une dépendance avec `db`
from boilerplate.modules.admin.api.user import UserAPI
auth = AutoAuth(auth_provider=header_auth('debug-user'),
                info_provider=UserAPI())
...
def create_app(name=__name__):

    ...
    auth.init_app(app)

Voilà, le système de gestion des droits est en place et s'appuie sur les objets que nous avons définis précédemment. Il ne reste plus qu'à voir comment les restrictions d'accès s'appliquent et comment le tout se teste.

Fermez les vannes !

Flask-Principal fournit un ensemble d'outils variés pour appliquer les droits d'accès. Pour illustrer cet article, nous n'en verrons qu'un seul, mais la documentation en ligne de FP est bien fournie.

Nous voulons donc limiter l'accès à l'API de gestion des rôles et utilisateurs aux seuls utilisateurs disposant du rôle Admin. Pour se faire, il suffit de définir une Permission requiérant le Role Admin et d'annoter les méthodes de l'API REST qui le nécessite (donc toutes).

Comme la permission sera la même pour les rôles et les utilisateurs, afin de factoriser le code, celle-ci sera définie dans le _init_.py de l'API.

modules/admin/api/init.py :

# -*- encoding: utf-8 -*-
"""
    admin.api
    ---------

    :copyright: (c) 2013 by Morgan Delahaye-Prat.
    :license: BSD, see LICENSE for more details.
"""


from __future__ import absolute_import
from .. import api

# Handle the permission

from flask.ext.principal import Permission, RoleNeed
admin_permission = Permission(RoleNeed('admin'))

# This will automatically register Rest APIs

from .role import RoleRestAPI
from .user import UserRestAPI

modules/admin/api/role.py :

...
from . import api, admin_permission
...

...
@api.multi_route('/roles', '/role/<string:name>')
class RoleRestAPI(RestWrapper):
    """A RESTful API wrapper over the standard API for roles."""

    api = RoleAPI()

    def transform(self, json, method=None):
        """Modify the received JSON before processing it."""
        super(RoleRestAPI, self).transform(json, method)
        json.pop('id', None)
        return json

    @admin_permission.require()
    def get(self, name=None):
        return super(RoleRestAPI, self).get(name)

    @admin_permission.require()
    def put(self, name):
        return super(RoleRestAPI, self).put(name)

    @admin_permission.require()
    def delete(self, name):
        return super(RoleRestAPI, self).delete(name)

    @admin_permission.require()
    def post(self):
        return super(RoleRestAPI, self).post()

Les modifications du fichier modules/admin/api/user.py sont analogues à celles du role.py et ne sont pas détaillées ici.

Hey ! What did you expect !

Le comportement attendu est le suivant : si l'utilisateur qui essaye d'accéder au service dispose du rôle admin alors la requête est traitée, sinon, l'utilisateur fait face à une erreur HTTP 403 Forbidden.

La protection d'une ressource exoposée via HTTP sans la mise en place d'un gestionnaire de droits AutoAuth est une source d'exceptions. Il est donc impossible de protéger un module dans une application n'utilisant pas de protection.

Tests (avec des vrais morceaux de tests unitaires dedans !)

Les curieux auront déjà pu voir qu'il existe dans le répertoire tests un fichier nommé rest.py. Ce fichier fournit une classe permettant de tester un webservice REST implémenté dans boilerplate facilement. Une bonne pratique est d'avoir un environnement dédié aux tests. Une très bonne pratique est d'avoir un environnement créé et détruit après chaque test ! De cette manière vos tests unitaires sont intégrés et ne dépendent d'aucun service (ahem seveur de base de données) extérieur. Ci-dessous, le code pour faire un test unitaire sur une méthode GET et POST de l'API REST de Role.

/tests/project/admin/role.py :

# -*- encoding: utf-8 -*-
"""
    test.abm.admin
    --------------

    Test the administration features

    :copyright: (c) 2013 by Morgan Delahaye-Prat.
    :license: BSD, see LICENSE for more details.
"""


from __future__ import absolute_import
from __future__ import unicode_literals

import abm

from abm.helpers.auth import AutoAuth
from abm.helpers.auth.providers import query_auth

from tests.rest import RestTestHelper
from nose.tools import assert_equal

from abm.modules.admin.api.role import RoleAPI
from abm.modules.admin.api.user import UserAPI


class TestAdminRoleRestAPI(RestTestHelper):

    SQLALCHEMY_DATABASE_URI = 'sqlite://'

    default_status = 200
    tests = {'get_valid': {'method': 'GET',
                           'uri': '/api/admin/role/admin?_test_user=admin',
                           'response': '{"active": true, "name": "admin"}'},

             'post_valid': {'method': 'POST',
                            'uri': '/api/admin/roles?_test_user=admin',
                            'status': 201,
                            'response': '{"active": false, "name": "test"}',
                            'data': {'name': 'test',
                                     'active': False}}}

    def setup(self):

        # monkey patching of the authentication helper
        abm.auth = AutoAuth(auth_provider=query_auth('_test_user'),
                            info_provider=UserAPI())

        super(TestAdminRoleRestAPI, self).setup()

        # create a user admin
        role = RoleAPI()
        user = UserAPI()
        with self.app.test_request_context():
            admin_role = role.create(name='admin', active=True)
            admin = user.create(user='admin', name='Administrator', active=True,
                                roles=[admin_role])

    def test_get_valid(self):
        self.process_entry('get_valid')

    def test_get_missing(self):
        self.process_entry('get_missing')


class TestAnonymousRoleRestAPI(TestAdminRoleRestAPI):

    default_status = 403
    tests = {'get_valid': {'method': 'GET',
                           'uri': '/api/admin/role/admin'},

             'post_valid': {'method': 'POST',
                            'uri': '/api/admin/roles',
                            'data': {'name': 'test',
                                     'active': False}}}

En résumé, TestAdminRoleRestAPI s'appuie sur la classe RestTestHelper qu'il étend. Cette dernière fournit un helper qui s'appuie sur le dict nommé tests pour définir la requête HTTP a exécuter et le résultat attendu. La méthode setup est elle en charge de mettre en place l'environnement de test qui sera réinitialisé entre chaque. Cet environnement de test met en place une base de donnée SQLite en mémoire, créé un utilisateur admin avec le rôle admin et patch le système d'authentification pour s'appuyer sur les paramètres de la requête pour définir l'utilisateur :

  • /une/uri?_test_user=admin : /une/uri est appelée par l'admin
  • /une/uri : /une/uri est appelée par un utilisateur anonyme

Toutes les requêtes de TestAdminRoleRestAPI sont exécutée en tant qu'admin. La classe TestAnonymousRoleRestAPI, exécute les même requête en tant qu'utilisateur anonyme et vérifie que le serveur refuse l'accès à chaque fois.

Bien entendu, ces tests sont à compléter pour couvrir l'ensemble des situations possibles ;-) .

Conclusion

Une fois que vous avez écrit tout vos test, vous pouvez vous lancer dans un test grandeur nature. Cependant, le système d'authentification utilise cette fois les en-têtes. Bien entendu, les exemples étaient ici vraiment triviaux. Un système d'authentification par mot de passe, par exemple, s'appuiera sur la session qui sera mise à jour après une authentification réussie sur une page de login.

Cependant, avec ce qu'il y a ici, les bases sont posées pour déployer un mécanisme d'ACL à travers une application. Bien entendu, la lecteur de la documentation de Flask-Principal ne reste pas superflue.

Prochaine étape, l'interface graphique (par avance, je préviens qu'il y aura du javascript dedans).

Aug 1, 2013

Un premier module pour boilerplate II

La deuxième partie de cet article commence à s'attaquer à la partie API. Afin de bien faire les choses, l'API est divisée en deux parties distinctes :

  • une exposant une API interne au programme et afin de présenter les fonctionnalités métiers de manière claire et réutilisable. (Appelé API dans le reste de l'article)

  • une autre exposant une API externe via HTTP, RESTful et conditionnelle afin que le monde extérieur puisse intéragir avec l'application. (Appelé webservice dans le reste de l'article)

A travers le cas de la classe Role, cet article va montrer comment utiliser les fonctionnalités de boilerplate pour rapidement mettre en place tout ça.

Arborescence du projet

Pour mettre en place ces APIs il faut modifier et ajouter quelques fichiers. Cela commence par la création d'un répertoire modules/admin/api et d'un fichier modules/admin/api/__init__.py.

L'arborescence doit maintenant ressembler à ça :

+
 \
  +-- runserver.py
  +-- project/
        \
         +-- __init__.py
         +-- helpers/
         +-- templates/
         +-- static/
         +-- modules/
               \
                +-- __init__.py
                +-- admin/
                      \
                       +-- __init__.py
                       +-- api/                 # la nouvelle zone de jeu
                       |     \
                       |      +-- __init__.py
                       |
                       +-- models/
                             \
                              +-- __init__.py
                              +-- role.py
                              +-- user.py

Et les fichiers suivants sont à modifier.

modules/admin/__init__.py :

# -*- encoding: utf-8 -*-
"""
admin
-----

:copyright: (c) 2013 by YOU
:license: BSD, see LICENSE for more details.
"""


from __future__ import absolute_import
from project.helpers.api import APIBlueprint    # project doit être le nom
                                                # de l'application

api = APIBlueprint('{}.api'.format(__name__), __name__)

APIBlueprint est un helper disponible dans boilerplate pour fournir un enregistrement des routes facilité pour un webservice RESTful élégant.

modules/admin/api/__init__.py :

# -*- encoding: utf-8 -*-
"""
admin.api
---------

:copyright: (c) 2013 by YOU
:license: BSD, see LICENSE for more details.
"""


from __future__ import absolute_import
from .. import api

Un simple import pour rendre api disponible dans le sous-module éponyme.

Hémisphère gauche

Un helper fourni dans boilerplate permet de créer rapidement une interface présentant les opérations CRUD. Il a été conçu pour s'interfacer avec un modèle SQLAlchemy out-of-the-box. Il nécessite donc que ce dernier dispose d'une propriété query (avec des méthodes get() et filter_by()) mais aussi que celui-ci dispose de méthodes save() et delete() permettant d'effectuer les opérations du même nom tout en s'occupant du commit ou rollback en cas de nécessité.

Cet helper, CrudAPI, peut s'utiliser tel quel à travers son constructeur attendant un modèle en paramètre :

my_api = CrudAPI(MyModel)

Dans cette configuration, les opérations CRUD vont utiliser la clé primaire du modèle comme identifiant unique d'un objet. Si une autre propriété doit être utilisée, l'argument optionnel key_name du constructeur permet de le faire. Par exemple :

my_api = CrudAPI(MyModel, key_name='name')

Pour centraliser la configuration d'une telle API, il peut être intéressant de créer une sous-classe de CrudAPI. C'est ce choix qui sera retenu pour générer l'API relative à la gestions des rôles.

modules/admin/api/role.py :

# -*- encoding: utf-8 -*-
"""
admin.api.role
--------------

Define the API to manipulate a role object and its HTTP wrapper.

:copyright: (c) 2013 by YOU
:license: BSD, see LICENSE for more details.
"""

from __future__ import absolute_import
from __future__ import unicode_literals

from . import api
from ..models import Role

# project est à remplacer par le nom du projet
from project.helpers.api import CrudAPI, RestWrapper


class RoleAPI(CrudAPI):
    """The API to manipulate a role."""
    def __init__(self):
        super(RoleAPI, self).__init__(Role, 'name')

Et c'est tout ! (Le rab d'import est en prévision de la suite ;) )

La classe CrudAPI implémente les méthodes suivantes pour ses instances :

  • RoleAPI.create(self, **kwargs)
  • RoleAPI.retrieve(self, id=None, **kwargs)
  • RoleAPI.update(self, id, **kwargs)
  • RoleAPI.delete(self, id)

Et voilà en six lignes, dochelp et imports compris, CrudAPI permet de construire une API complète pour réaliser les opérations CRUD de base. Mais cette solution ne conviendra qu'à un nombre réduit de situations. Pour toutes les autres il faudra compléter cette classe voir en écrire une from-scratch.

Hémisphère droit

La suite logique consiste à exposer cette nouvelle API via un webservice RESTful en charge de faire le lien vers les verbes HTTP. Le client passera la donnée utile au format JSON dans le payload de la requête.

Le tableau ci-dessous résume la correspondance HTTP - CRUD.

Méthode HTTP Méthode API Remarques
GET retrieve filtres en paramètres de la requête
POST create
PUT update conditionnel / idempotent
DELETE delete conditionnel

Les requêtes conditionnelles sont des requêtes qui vont nécessiter la présence de l'en-tête HTTP "if-match" avec la dernière valeur de ETag connue par le client. Ce mécanisme agit comme un contrôle optimiste de la concurrence (OCC) sur les ressources que l'on cherche à modifier.

Note : la présence des ETags permet aussi une gestion efficace du cache côté client lors d'un GET et ainsi permettre une économie de bande passante.

Le webservice suit les spécifications de la norme HTTP dans la formulation de ses réponses.

Comme pour CrudAPI, boilerplate fournit une classe de base pourmettre en place cette interface RESTful en quelques lignes :

@api.multi_route('/items', '/item/<key>')
class MyRestAPI(RestWrapper):
    api = CrudAPI(MyModel)

L'enregistrement des URIs du webservice se fait par l'intermédiaire du helper APIBlueprint précédemment évoqué. Sa méthode multi_route agissant comme un décorateur permettant d'associer plusieurs route en une fois.

Par défaut, les méthodes HTTP nécessitant un identifiant de ressource (qui sera utilisé comme identifiant unique dans l'API CRUD) l'attendent sous le nom du paramètre key.

Dans le cas de Role, pour que le code soit uniforme et clair, le paramètre sera nommé name et nécessitera la redéfinition des signatures des méthodes.

Il suffit d'ajouter au fichier modules/admin/api/role.py les lignes suivantes :

@api.multi_route('/roles', '/role/<string:name>')
class RoleRestAPI(RestWrapper):
    """A RESTful API wrapper over the standard API for roles."""

    api = RoleAPI()

    def transform(self, json, method=None):
        """Modify the received JSON before processing it."""
        super(RoleRestAPI, self).transform(json, method)
        json.pop('id', None)
        return json

    # for readability purpose :

    def get(self, name=None):
        return super(RoleRestAPI, self).get(name)

    def put(self, name):
        return super(RoleRestAPI, self).put(name)

    def delete(self, name):
        return super(RoleRestAPI, self).delete(name)

La méthode transform() modifie le JSON envoyé par le client avant de le traiter.

Dans le cas de Role, la propriété id, existante au niveau du modèle, ne doit pas être modifiable par l'utilisateur du webservice.
Pour cette raison, celle-ci est toujours supprimée du JSON transmis. Cependant, cette mesure sanitaire ne représente pas une mesure de sécurité suffisante pour le contrôle des données envoyées par l'utilisateur. (cela fera l'objet d'un prochain article)

Note : par défaut, la méthode transform() enlève toutes les propriétés préfixées par un underscore car considérées comme des propriétés privées du client.

Enregistrement de l'API

Pour enregistrer le webservice nouvellement créé, il suffit de modifier le fichier modules/admin/api/__init__.py et d'y ajouter la ligne suivante :

from .role import RoleRestAPI

Au prochain démarrage du serveur, il sera disponible aux URLs :

http://yourserver/api/admin/roles   (pour l'ensemble des rôles)
http://yourserver/api/admin/role/XX (pour le rôle XX, avec XX un entier)

Test grandeur nature

Je ne parle pas de test unitaires ici. Mais de test in-situ.

Note : si vous ne savez pas comment vous y prendre pour les tests unitaires, jetez un coup d'oeil à l'archive du code source qui accompagne cette série d'articles.

Pour essayer cette API, il vous suffit d'avoir de quoi faire des requêtes HTTP. La commande curl est idéale pour cela. Quelques exemples :

$ # un simple GET
$ curl http://127.0.0.1:5000/api/admin/roles
{"content": [{"active": true, "id": 3, "name": "admin"}, {"active": false, "id":
5, "name": "demo"}], "type": "role"}

$ # un GET filtrant
$ curl http://127.0.0.1:5000/api/admin/roles?active=true
{"content": [{"active": true, "id": 3, "name": "admin"}], "type": "role"}

$ # créer un nouveau rôle nommé test et inactif.
$ curl -X POST -H 'Content-Type: application/json' \
               -d '{"name": "test"}' http://127.0.0.1:5000/api/admin/roles
{"active": false, "id": 7, "name": "test"}

$ # mettre à jour ce rôle
$ curl -X PUT -H 'Content-Type: application/json' \
              -H 'If-Match: "ea456254148832d2bb2fc4e3c355d1353d22343' \
              -d '{"active": true}'  http://127.0.0.1:5000/api/admin/role/5
{"active": true, "id": 7, "name": "demo"}

Bientôt fini !

Il ne nous reste plus qu'à faire de même pour User. Globalement l'API et le webservice exposent les mêmes fonctionnalités que pour Role. À noter que les rôles affectés à un utilisateur sont passés par une liste de noms (ex : ['user', 'demo', 'admin']). L'API attend elle des instances de la classe Role. Cette tâche de conversion incombera à la méthode transform().

modules/admin/api/user.py :

# -*- encoding: utf-8 -*-
"""
    admin.api.user
    --------------

    :copyright: (c) 2013 by Morgan YOU.
    :license: BSD, see LICENSE for more details.
"""

from __future__ import absolute_import
from __future__ import unicode_literals

from . import api
from .role import RoleAPI
from ..models import User

from flask import abort
from abm.helpers.api import CrudAPI, RestWrapper


class UserAPI(CrudAPI):
    """The API to manipulate an user."""

    def __init__(self):
        super(UserAPI, self).__init__(User, 'username')
        pass

@api.multi_route('/users', '/user/<string:username>')
class UserRestAPI(RestWrapper):
    """
    """

    api = UserAPI()
    roles = RoleAPI()

    def transform(self, json, method=None):
        """Modify the received JSON before processing it."""
        super(UserRestAPI, self).transform(json, method)
        json.pop('id', None)

        # convert a list of role names in their instance.
        json['roles'] = [self.roles.retrieve(role)
                        for role in json.get('roles', list())]

        # and abort if it exists an unknown role in the list
        if None in json['roles']:
            abort(400)

        return json

    def get(self, username=None):
        return super(UserRestAPI, self).get(username)

    def put(self, username):
        return super(UserRestAPI, self).put(username)

    def delete(self, username):
        return super(UserRestAPI, self).delete(username)

Et pour enregistrer le webservice, ajouter dans le fichier modules/admin/api/__init__.py :

from .user import UserRestAPI

La suite

Dans le prochain article, avant de nous occuper du client graphique permettant de gérer les utilisateurs et leurs droits, nous verrons comment utiliser ces informations avec le mécanisme d'ACL.

Jul 30, 2013

Un premier module pour boilerplate I

Ce premier module va servir à quelque chose de très courant, tellement courant qu'il pourrait trouver sa place dans le dépôt Git de boilerplate. Il s'agit d'un module de gestion des utilisateurs : on associe un identifiant propre au système à un ensemble de données représentant l'utilisateur. La fin inavouée de ce module est de fournir les ressources nécessaires aux mécanismes d'ACL.

Pour info : Ce module s'appuiera sur une architecture orientée service parce que c'est cool et que j'ai dit que boilerplate était très influencé par ça.

Arborescence du projet

Tout d'abord un rapide coup d'oeil à l'arborescence du projet dans lequel nous allons rajouter notre module admin :

+                                               # racine de votre projet
 \
  +-- runserver.py                              # serveur de test
  +-- project/                                  # sources de votre projet
        \
         +-- __init__.py                        # boilerplate
         +-- helpers/                           # helpers
         +-- templates/                         # templates
         +-- static/                            # fichiers statiques
         +-- modules/                           # modules
               \
                +-- __init__.py
                +-- admin/                      # nouveau module 'admin'
                      \
                       +-- __init__.py

À partir d'ici, vous êtes libre d'ajouter ce que vous voulez à votre module, par contre s'il y a des sous modules nommés api, ui ou models dans votre module, boilerplate tentera de les charger automatiquement. En leur absence, vous aurez le droit à un warning dans le système de log mais rien de plus.

Enregistrement du module

Pour des raisons de conception et de sécurité, les modules présents dans le répertoire modules ne sont pas chargés automatiquement : Explicit is better than implicit. Il faut donc les enregistrer en ajoutant des informations à la liste MODULES présente dans le fichier project/__init__.py.

Cela se fait via un dictionnaire avec une clé name pour le nom du module et une clé url_prefix pour le préfixe des URLs dudit module, que l'on ajoute à cette liste. Dans notre cas :

MODULES = [
    {'name': 'admin', 'url_prefix': '/admin'},
]

Si vous lancez le serveur de développement maintenant, vous aurez le droit à trois beaux warnings nous indiquant que le programme ne trouve pas models, ui et api pour le module admin. Ceci est non bloquant.

Définition des modèles

Rien que du très traditionnel ici, à savoir utiliser une base de donnée relationnelle avec Flask-SQLAlchemy.

Pout info : SQLAlchemy, n'est vraiment pas obligatoire. Vous pouvez tout à fait utiliser ce qui vous chante, MongoDB, un gros dict, etc. Cependant, boilerplate/__init__.py possède un exemple d'utilisation de SQLAlchemy.

Un mécanisme d'ACL se base sur le principe suivant : un utilisateur possède des droits (rôles) utilisés pour savoir si celui-ci peut accéder ou non à certaines opérations.

Il y a donc besoin d'un objet représentant l'utilisateur (User), avec un identifiant unique (id) pour les opérations en base de donnée et un identifiant unique permettant de l'identifier dans le programme (user). À cela s'ajoute le nom du compte (name), les rôles qui lui sont associés (roles) et un booléen pour déterminer la validité du compte (active).

Il faut aussi un objet représentant un rôle (Role), caractérisé par son identifiant (id), un nom (name) et un booléen pour sa validité (active).

Le code suivant se charge de faire tout ça.

modules/admin/models/role.py :

# -*- encoding: utf-8 -*-
"""
admin.models.role
-----------------

Provides a representation of a Role stored in the database.

:copyright: (c) 2013 by YOU
:license: BSD, see LICENSE for more details.
"""


from __future__ import absolute_import
from __future__ import unicode_literals

from project import db  # this is the db object of SQLA set in boilerplate
from sqlalchemy.exc import IntegrityError

class Role(db.Model):
    """Implements th role model."""

    __tablename__ = 'roles'

    id = db.Column(db.Integer, primary_key=True)

    name = db.Column(db.String(128), unique=True, nullable=False)
    active = db.Column(db.Boolean, nullable=False, default=False)

    def __init__(self, name, active=False):
        self.name = name
        self.active = active

    def __json__(self):
        """Returns a dict for the JSON serialization."""
        return {'name': self.name,
                'active': self.active}

    def save(self):
        """Save the current instance of the object in the database."""
        try:
            db.session.add(self)
            db.session.commit()
        except IntegrityError as e:
            db.session.rollback()
            raise e

    def delete(self):
        """Delete the current instance of the object in the database."""
        try:
            db.session.delete(self)
            db.session.commit()
        except IntegrityError as e:
            db.session.rollback()
            raise e

Comme le montre ce snippet, rien de bien difficile ici. À noter que save et delete sont des méthodes optionnelles qui servent d'API bas niveau. Cela est utile pour les opérations atomiques sur un Role et évitera de se répéter quand on définira une API plus complète. Le constructeur est un premier niveau de sécurité en filtrant les paramètres acceptables lors de la création d'une nouvelle instance d'un rôle.

modules/admin/models/user.py :

# -*- encoding: utf-8 -*-
"""
admin.models.user
-----------------

Provides a representation for the module of a User stored in the DB

:copyright: (c) 2013 by YOU
:license: BSD, see LICENSE for more details.
"""


from __future__ import absolute_import
from __future__ import unicode_literals

from project import db

# many-to-many relationship table
roles = db.Table('user_roles',
    db.Column('role_id', db.Integer, db.ForeignKey('roles.id')),
    db.Column('user_id', db.Integer, db.ForeignKey('users.id'))
)


class User(db.Model):
    """Implements the user model."""

    __tablename__ = 'users'

    id = db.Column(db.Integer, primary_key=True)

    user = db.Column(db.String(128), unique=True, nullable=False)
    name = db.Column(db.String(128), nullable=False)
    roles = db.relationship('Role', secondary=roles)
    active = db.Column(db.Boolean, nullable=False, default=False)

    def __init__(self, user, name, roles=None, active=False):

        self.user = user
        self.name = name
        self.active = active

        if roles is None:
            self.roles = list()
        else:
            self.roles = roles

    def __json__(self):
        """Returns a dict for the JSON serialization."""
        return {'user': self.user,
                'name': self.name,
                'roles': self.roles,
                'active': self.active}

    def save(self):
        """Save the current instance of the object in the database."""
        try:
            db.session.add(self)
            db.session.commit()
        except IntegrityError as e:
            db.session.rollback()
            raise e

    def delete(self):
        """Delete the current instance of the object in the database."""
        try:
            db.session.delete(self)
            db.session.commit()
        except IntegrityError as e:
            db.session.rollback()
            raise e

L'objet représentant un utilisateur est un peu plus "complexe" que celui représentant un role, en cause la table de jointure pour réaliser des liaisons N-to-N entre les rôles et l'utilisateur.

Enregistrement des modèles

Il ne reste plus grand chose à faire pour enregistrer les modèles, pour cela il suffit de réouvrir le fichier admin/models/__init__.py et y ajouter ces lignes à la suite de son contenu :

from .role import Role
from .user import User

Et le tour est joué, si db.create_all() est appelé, les modifications de la base de données seront effectuées pour avoir de nouvelles tables.

La suite

Dans la deuxième partie de cet article, j'attaquerai l'API du module.

Jul 29, 2013

Présentation de boilerplate

Utiliser un micro-framework c'est génial quand on fait de petits projets, mais dès qu'il s'agit de passer à l'échelle il faut avoir une architecture un peu plus carrée. Sans tomber dans le monde des frameworks full-featured (à la Django), boilerplate fournit une base pour un "gros" projet web basé sur Flask.

Une base simple, des projets complexes

L'idée de boilerplate est de conserver l'esprit de simplicité derrière Flask. Essentiellement, il ne s'agit que d'un fichier __init__.py pour la racine de votre application, mais ce dernier va organiser celle-ci sous forme de modules.

Grosso modo, un module = un préfixe d'URI.

Mais ces modules ne sont pas que des simples Blueprints comme le propose déjà Flask, en réalité ils se composent de trois sous-modules :

  • module.models, un package qui sera importé au démarrage de l'application. Idéal pour gérer la déclaration des modèles.
  • module.ui, un Blueprint Flask qui sera routé et préfixé par l'URI associée au module.
  • module.api, un Blueprint Flask qui sera routé et préfixé par "/api" et l'URI du module. Le préfixe "/api" peut être modifié dans la configuration de l'application.

Bien entendu, on reste dans la philosophie micro-framework et donc tout cela n'est pas obligatoire, mais boilerplate essayera de charger ces trois sous-modules de votre module.

Pour 1€ de plus

Cette base est agrémentée de petits outils sympa®, comme une API CRUD prête à l'emploi son wrapper pour l'exposer à travers une API RESTful complète et de quoi la tester automatiquement. D'ailleurs cette API REST s'appuie sur une vue proposant le support complet des requêtes conditionnelles (avec les ETags).

Il y a aussi le support de Coffeescript et de Slimit permettant d'écrire du beau code client d'un côté et de servir une bouse infâme (lire ici du Javascript minifié) de l'autre.

De quoi mettre en place un méchanisme d'ACL en l'espace de 42 secondes, etc.

L'idée du siècle

L'idée du siècle derrière boilerplate c'est aussi de fournir une architecture moderne. Bye-bye le MVC de papa, bonjour le Service Oriented Architecture. (Il faut dire qu'avec les trois sous-modules si vous aviez pas vu le truc venir...) Bref, tout est là pour ça, on fait son modèle de données, ensuite on fait son API métier. On l'expose à travers une API RESTful. En même temps on développe le client qui tape l'API REST, et comme on est moderne on fait ça en CS en utilisant un framework MVVM côté client.

Il ne manque plus qu'un liant dans tout ça pour que ça communique d'un bout à l'autre de manière sécurisée ^_^

Bref (TL;DR)

J'ai fait un boilerplate pour une application web moderne basée sur Flask et il est disponible sur mon Github