Adding authorization¶
In the last chapter we built authentication into our wiki. We also
went one step further and used the request.user object to perform some
explicit authorization checks. This is fine for a lot of applications,
but Pyramid provides some facilities for cleaning this up and decoupling
the constraints from the view function itself.
We will implement access control with the following steps:
Update the authentication policy to break down the userid into a list of principals (
security.py).Define an authorization policy for mapping users, resources and permissions (
security.py).Add new resource definitions that will be used as the context for the wiki pages (
routes.py).Add an ACL to each resource (
routes.py).Replace the inline checks on the views with permission declarations (
views/default.py).
Add user principals¶
A principal is a level of abstraction on top of the raw userid that describes the user in terms of its capabilities, roles, or other identifiers that are easier to generalize. The permissions are then written against the principals without focusing on the exact user involved.
Pyramid defines two builtin principals used in every application:
pyramid.security.Everyone and pyramid.security.Authenticated.
On top of these we have already mentioned the required principals for this
application in the original design. The user has two possible roles: editor
or basic. These will be prefixed by the string role: to avoid clashing
with any other types of principals.
Open the file tutorial/security.py and edit it as follows:
 1from pyramid.authentication import AuthTktAuthenticationPolicy
 2from pyramid.authorization import ACLAuthorizationPolicy
 3from pyramid.security import (
 4    Authenticated,
 5    Everyone,
 6)
 7
 8from . import models
 9
10
11class MyAuthenticationPolicy(AuthTktAuthenticationPolicy):
12    def authenticated_userid(self, request):
13        user = request.user
14        if user is not None:
15            return user.id
16
17    def effective_principals(self, request):
18        principals = [Everyone]
19        user = request.user
20        if user is not None:
21            principals.append(Authenticated)
22            principals.append(str(user.id))
23            principals.append('role:' + user.role)
24        return principals
25
26def get_user(request):
27    user_id = request.unauthenticated_userid
28    if user_id is not None:
29        user = request.dbsession.query(models.User).get(user_id)
30        return user
31
32def includeme(config):
33    settings = config.get_settings()
34    authn_policy = MyAuthenticationPolicy(
35        settings['auth.secret'],
36        hashalg='sha512',
37    )
38    config.set_authentication_policy(authn_policy)
39    config.set_authorization_policy(ACLAuthorizationPolicy())
40    config.add_request_method(get_user, 'user', reify=True)
Only the highlighted lines need to be added.
Note that the role comes from the User object. We also add the user.id
as a principal for when we want to allow that exact user to edit pages which
they have created.
Add the authorization policy¶
We already added the authorization policy in the previous chapter because Pyramid requires one when adding an authentication policy. However, it was not used anywhere, so we'll mention it now.
In the file tutorial/security.py, notice the following lines:
38    config.set_authentication_policy(authn_policy)
39    config.set_authorization_policy(ACLAuthorizationPolicy())
40    config.add_request_method(get_user, 'user', reify=True)
We're using the pyramid.authorization.ACLAuthorizationPolicy, which
will suffice for most applications. It uses the context to define the
mapping between a principal and permission for the current
request via the __acl__.
Add resources and ACLs¶
Resources are the hidden gem of Pyramid. You've made it!
Every URL in a web application represents a resource (the "R" in Uniform Resource Locator). Often the resource is something in your data model, but it could also be an abstraction over many models.
Our wiki has two resources:
A
NewPage. Represents a potentialPagethat does not exist. Any logged-in user, having either role ofbasicoreditor, can create pages.A
PageResource. Represents aPagethat is to be viewed or edited.editorusers, as well as the original creator of thePage, may edit thePageResource. Anyone may view it.
Note
The wiki data model is simple enough that the PageResource is mostly
redundant with our models.Page SQLAlchemy class. It is completely valid
to combine these into one class. However, for this tutorial, they are
explicitly separated to make clear the distinction between the parts about
which Pyramid cares versus application-defined objects.
There are many ways to define these resources, and they can even be grouped into collections with a hierarchy. However, we're keeping it simple here!
Open the file tutorial/routes.py and edit the following lines:
 1from pyramid.httpexceptions import (
 2    HTTPNotFound,
 3    HTTPFound,
 4)
 5from pyramid.security import (
 6    Allow,
 7    Everyone,
 8)
 9
10from . import models
11
12def includeme(config):
13    config.add_static_view('static', 'static', cache_max_age=3600)
14    config.add_route('view_wiki', '/')
15    config.add_route('login', '/login')
16    config.add_route('logout', '/logout')
17    config.add_route('view_page', '/{pagename}', factory=page_factory)
18    config.add_route('add_page', '/add_page/{pagename}',
19                     factory=new_page_factory)
20    config.add_route('edit_page', '/{pagename}/edit_page',
21                     factory=page_factory)
22
23def new_page_factory(request):
24    pagename = request.matchdict['pagename']
25    if request.dbsession.query(models.Page).filter_by(name=pagename).count() > 0:
26        next_url = request.route_url('edit_page', pagename=pagename)
27        raise HTTPFound(location=next_url)
28    return NewPage(pagename)
29
30class NewPage(object):
31    def __init__(self, pagename):
32        self.pagename = pagename
33
34    def __acl__(self):
35        return [
36            (Allow, 'role:editor', 'create'),
37            (Allow, 'role:basic', 'create'),
38        ]
39
40def page_factory(request):
41    pagename = request.matchdict['pagename']
42    page = request.dbsession.query(models.Page).filter_by(name=pagename).first()
43    if page is None:
44        raise HTTPNotFound
45    return PageResource(page)
46
47class PageResource(object):
48    def __init__(self, page):
49        self.page = page
50
51    def __acl__(self):
52        return [
53            (Allow, Everyone, 'view'),
54            (Allow, 'role:editor', 'edit'),
55            (Allow, str(self.page.creator_id), 'edit'),
56        ]
The highlighted lines need to be edited or added.
The NewPage class has an __acl__ on it that returns a list of mappings
from principal to permission. This defines who can do what
with that resource. In our case we want to allow only those users with
the principals of either role:editor or role:basic to have the
create permission:
30class NewPage(object):
31    def __init__(self, pagename):
32        self.pagename = pagename
33
34    def __acl__(self):
35        return [
36            (Allow, 'role:editor', 'create'),
37            (Allow, 'role:basic', 'create'),
38        ]
The NewPage is loaded as the context of the add_page route by
declaring a factory on the route:
18    config.add_route('add_page', '/add_page/{pagename}',
19                     factory=new_page_factory)
The PageResource class defines the ACL for a Page. It uses an
actual Page object to determine who can do what to the page.
47class PageResource(object):
48    def __init__(self, page):
49        self.page = page
50
51    def __acl__(self):
52        return [
53            (Allow, Everyone, 'view'),
54            (Allow, 'role:editor', 'edit'),
55            (Allow, str(self.page.creator_id), 'edit'),
56        ]
The PageResource is loaded as the context of the view_page and
edit_page routes by declaring a factory on the routes:
17    config.add_route('view_page', '/{pagename}', factory=page_factory)
18    config.add_route('add_page', '/add_page/{pagename}',
19                     factory=new_page_factory)
20    config.add_route('edit_page', '/{pagename}/edit_page',
21                     factory=page_factory)
Add view permissions¶
At this point we've modified our application to load the PageResource,
including the actual Page model in the page_factory. The
PageResource is now the context for all view_page and
edit_page views. Similarly the NewPage will be the context for the
add_page view.
Open the file tutorial/views/default.py.
First, you can drop a few imports that are no longer necessary:
5from pyramid.httpexceptions import HTTPFound
6from pyramid.view import view_config
7
Edit the view_page view to declare the view permission, and remove the
explicit checks within the view:
18@view_config(route_name='view_page', renderer='../templates/view.jinja2',
19             permission='view')
20def view_page(request):
21    page = request.context.page
22
23    def add_link(match):
The work of loading the page has already been done in the factory, so we can
just pull the page object out of the PageResource, loaded as
request.context. Our factory also guarantees we will have a Page, as it
raises the HTTPNotFound exception if no Page exists, again simplifying
the view logic.
Edit the edit_page view to declare the edit permission:
38@view_config(route_name='edit_page', renderer='../templates/edit.jinja2',
39             permission='edit')
40def edit_page(request):
41    page = request.context.page
42    if 'form.submitted' in request.params:
Edit the add_page view to declare the create permission:
52@view_config(route_name='add_page', renderer='../templates/edit.jinja2',
53             permission='create')
54def add_page(request):
55    pagename = request.context.pagename
56    if 'form.submitted' in request.params:
Note the pagename here is pulled off of the context instead of
request.matchdict. The factory has done a lot of work for us to hide the
actual route pattern.
The ACLs defined on each resource are used by the authorization
policy to determine if any principal is allowed to have some
permission. If this check fails (for example, the user is not logged
in) then an HTTPForbidden exception will be raised automatically. Thus
we're able to drop those exceptions and checks from the views themselves.
Rather we've defined them in terms of operations on a resource.
The final tutorial/views/default.py should look like the following:
 1from pyramid.compat import escape
 2import re
 3from docutils.core import publish_parts
 4
 5from pyramid.httpexceptions import HTTPFound
 6from pyramid.view import view_config
 7
 8from .. import models
 9
10# regular expression used to find WikiWords
11wikiwords = re.compile(r"\b([A-Z]\w+[A-Z]+\w+)")
12
13@view_config(route_name='view_wiki')
14def view_wiki(request):
15    next_url = request.route_url('view_page', pagename='FrontPage')
16    return HTTPFound(location=next_url)
17
18@view_config(route_name='view_page', renderer='../templates/view.jinja2',
19             permission='view')
20def view_page(request):
21    page = request.context.page
22
23    def add_link(match):
24        word = match.group(1)
25        exists = request.dbsession.query(models.Page).filter_by(name=word).all()
26        if exists:
27            view_url = request.route_url('view_page', pagename=word)
28            return '<a href="%s">%s</a>' % (view_url, escape(word))
29        else:
30            add_url = request.route_url('add_page', pagename=word)
31            return '<a href="%s">%s</a>' % (add_url, escape(word))
32
33    content = publish_parts(page.data, writer_name='html')['html_body']
34    content = wikiwords.sub(add_link, content)
35    edit_url = request.route_url('edit_page', pagename=page.name)
36    return dict(page=page, content=content, edit_url=edit_url)
37
38@view_config(route_name='edit_page', renderer='../templates/edit.jinja2',
39             permission='edit')
40def edit_page(request):
41    page = request.context.page
42    if 'form.submitted' in request.params:
43        page.data = request.params['body']
44        next_url = request.route_url('view_page', pagename=page.name)
45        return HTTPFound(location=next_url)
46    return dict(
47        pagename=page.name,
48        pagedata=page.data,
49        save_url=request.route_url('edit_page', pagename=page.name),
50        )
51
52@view_config(route_name='add_page', renderer='../templates/edit.jinja2',
53             permission='create')
54def add_page(request):
55    pagename = request.context.pagename
56    if 'form.submitted' in request.params:
57        body = request.params['body']
58        page = models.Page(name=pagename, data=body)
59        page.creator = request.user
60        request.dbsession.add(page)
61        next_url = request.route_url('view_page', pagename=pagename)
62        return HTTPFound(location=next_url)
63    save_url = request.route_url('add_page', pagename=pagename)
64    return dict(pagename=pagename, pagedata='', save_url=save_url)
Viewing the application in a browser¶
We can finally examine our application in a browser (See Start the application). Launch a browser and visit each of the following URLs, checking that the result is as expected:
http://localhost:6543/ invokes the
view_wikiview. This always redirects to theview_pageview of theFrontPagepage object. It is executable by any user.http://localhost:6543/FrontPage invokes the
view_pageview of theFrontPagepage object. There is a "Login" link in the upper right corner while the user is not authenticated, else it is a "Logout" link when the user is authenticated.http://localhost:6543/FrontPage/edit_page invokes the
edit_pageview for theFrontPagepage object. It is executable by only theeditoruser. If a different user (or the anonymous user) invokes it, then a login form will be displayed. Supplying the credentials with the usernameeditorand passwordeditorwill display the edit page form.http://localhost:6543/add_page/SomePageName invokes the
add_pageview for a page. If the page already exists, then it redirects the user to theedit_pageview for the page object. It is executable by either theeditororbasicuser. If a different user (or the anonymous user) invokes it, then a login form will be displayed. Supplying the credentials with either the usernameeditorand passwordeditor, or usernamebasicand passwordbasic, will display the edit page form.http://localhost:6543/SomePageName/edit_page invokes the
edit_pageview for an existing page, or generates an error if the page does not exist. It is editable by thebasicuser if the page was created by that user in the previous step. If, instead, the page was created by theeditoruser, then the login page should be shown for thebasicuser.After logging in (as a result of hitting an edit or add page and submitting the login form with the
editorcredentials), we'll see a "Logout" link in the upper right hand corner. When we click it, we're logged out, redirected back to the front page, and a "Login" link is shown in the upper right hand corner.