Adding authorization¶
In the last chapter we built authentication into our wiki. We also
went one step further and used the request.identity
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 security policy to break down the identity into a list of principals (
security.py
).Utilize the
pyramid.authorization.ACLHelper
to support a per-context mapping of principals to 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 ACL support¶
A principal is a level of abstraction on top of the raw identity 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.authorization.Everyone
and pyramid.authorization.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 AuthTktCookieHelper
2from pyramid.authorization import (
3 ACLHelper,
4 Authenticated,
5 Everyone,
6)
7from pyramid.csrf import CookieCSRFStoragePolicy
8from pyramid.request import RequestLocalCache
9
10from . import models
11
12
13class MySecurityPolicy:
14 def __init__(self, secret):
15 self.authtkt = AuthTktCookieHelper(secret)
16 self.identity_cache = RequestLocalCache(self.load_identity)
17 self.acl = ACLHelper()
18
19 def load_identity(self, request):
20 identity = self.authtkt.identify(request)
21 if identity is None:
22 return None
23
24 userid = identity['userid']
25 user = request.dbsession.query(models.User).get(userid)
26 return user
27
28 def identity(self, request):
29 return self.identity_cache.get_or_create(request)
30
31 def authenticated_userid(self, request):
32 user = self.identity(request)
33 if user is not None:
34 return user.id
35
36 def remember(self, request, userid, **kw):
37 return self.authtkt.remember(request, userid, **kw)
38
39 def forget(self, request, **kw):
40 return self.authtkt.forget(request, **kw)
41
42 def permits(self, request, context, permission):
43 principals = self.effective_principals(request)
44 return self.acl.permits(context, principals, permission)
45
46 def effective_principals(self, request):
47 principals = [Everyone]
48 user = self.identity(request)
49 if user is not None:
50 principals.append(Authenticated)
51 principals.append('u:' + str(user.id))
52 principals.append('role:' + user.role)
53 return principals
54
55def includeme(config):
56 settings = config.get_settings()
57
58 config.set_csrf_storage_policy(CookieCSRFStoragePolicy())
59 config.set_default_csrf_options(require_csrf=True)
60
61 config.set_security_policy(MySecurityPolicy(settings['auth.secret']))
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.
We're using the pyramid.authorization.ACLHelper
, 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__
method or attribute.
The permits
method completes our implementation of the pyramid.interfaces.ISecurityPolicy
interface and enables our application to use pyramid.request.Request.has_permission
and the permission=
constraint on views.
Add resources and ACLs¶
Resources and context are the hidden gems 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 potentialPage
that does not exist. Any logged-in user, having either role ofbasic
oreditor
, can create pages.A
PageResource
. Represents aPage
that is to be viewed or edited.editor
users, 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.authorization import (
2 Allow,
3 Everyone,
4)
5from pyramid.httpexceptions import (
6 HTTPNotFound,
7 HTTPSeeOther,
8)
9
10from . import models
11
12
13def includeme(config):
14 config.add_static_view('static', 'static', cache_max_age=3600)
15 config.add_route('view_wiki', '/')
16 config.add_route('login', '/login')
17 config.add_route('logout', '/logout')
18 config.add_route('view_page', '/{pagename}', factory=page_factory)
19 config.add_route('add_page', '/add_page/{pagename}',
20 factory=new_page_factory)
21 config.add_route('edit_page', '/{pagename}/edit_page',
22 factory=page_factory)
23
24def new_page_factory(request):
25 pagename = request.matchdict['pagename']
26 if request.dbsession.query(models.Page).filter_by(name=pagename).count() > 0:
27 next_url = request.route_url('edit_page', pagename=pagename)
28 raise HTTPSeeOther(location=next_url)
29 return NewPage(pagename)
30
31class NewPage:
32 def __init__(self, pagename):
33 self.pagename = pagename
34
35 def __acl__(self):
36 return [
37 (Allow, 'role:editor', 'create'),
38 (Allow, 'role:basic', 'create'),
39 ]
40
41def page_factory(request):
42 pagename = request.matchdict['pagename']
43 page = request.dbsession.query(models.Page).filter_by(name=pagename).first()
44 if page is None:
45 raise HTTPNotFound
46 return PageResource(page)
47
48class PageResource:
49 def __init__(self, page):
50 self.page = page
51
52 def __acl__(self):
53 return [
54 (Allow, Everyone, 'view'),
55 (Allow, 'role:editor', 'edit'),
56 (Allow, 'u:' + str(self.page.creator_id), 'edit'),
57 ]
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:
31class NewPage:
32 def __init__(self, pagename):
33 self.pagename = pagename
34
35 def __acl__(self):
36 return [
37 (Allow, 'role:editor', 'create'),
38 (Allow, 'role:basic', 'create'),
39 ]
The NewPage
is loaded as the context of the add_page
route by
declaring a factory
on the route:
19 config.add_route('add_page', '/add_page/{pagename}',
20 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.
48class PageResource:
49 def __init__(self, page):
50 self.page = page
51
52 def __acl__(self):
53 return [
54 (Allow, Everyone, 'view'),
55 (Allow, 'role:editor', 'edit'),
56 (Allow, 'u:' + str(self.page.creator_id), 'edit'),
57 ]
The PageResource
is loaded as the context of the view_page
and
edit_page
routes by declaring a factory
on the routes:
18 config.add_route('view_page', '/{pagename}', factory=page_factory)
19 config.add_route('add_page', '/add_page/{pagename}',
20 factory=new_page_factory)
21 config.add_route('edit_page', '/{pagename}/edit_page',
22 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:
3from pyramid.httpexceptions import HTTPSeeOther
4from pyramid.view import view_config
5import re
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='tutorial: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='tutorial:templates/edit.jinja2',
39 permission='edit')
40def edit_page(request):
41 page = request.context.page
42 if request.method == 'POST':
Edit the add_page
view to declare the create
permission:
52@view_config(route_name='add_page', renderer='tutorial:templates/edit.jinja2',
53 permission='create')
54def add_page(request):
55 pagename = request.context.pagename
56 if request.method == 'POST':
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 security
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 docutils.core import publish_parts
2from html import escape
3from pyramid.httpexceptions import HTTPSeeOther
4from pyramid.view import view_config
5import re
6
7from .. import models
8
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 HTTPSeeOther(location=next_url)
17
18@view_config(route_name='view_page', renderer='tutorial: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='tutorial:templates/edit.jinja2',
39 permission='edit')
40def edit_page(request):
41 page = request.context.page
42 if request.method == 'POST':
43 page.data = request.params['body']
44 next_url = request.route_url('view_page', pagename=page.name)
45 return HTTPSeeOther(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='tutorial:templates/edit.jinja2',
53 permission='create')
54def add_page(request):
55 pagename = request.context.pagename
56 if request.method == 'POST':
57 body = request.params['body']
58 page = models.Page(name=pagename, data=body)
59 page.creator = request.identity
60 request.dbsession.add(page)
61 next_url = request.route_url('view_page', pagename=pagename)
62 return HTTPSeeOther(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_wiki
view. This always redirects to theview_page
view of theFrontPage
page object. It is executable by any user.http://localhost:6543/login invokes the
login
view, and a login form will be displayed. On every page, 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.Supplying the credentials with either the username
editor
and passwordeditor
, or usernamebasic
and passwordbasic
, will authenticate the user and grant access for that group.After logging in (as a result of hitting an edit or add page and submitting valid credentials), we will see a "Logout" link in the upper right hand corner. When we click it, we are logged out, redirected back to the front page, and a "Login" link is shown in the upper right hand corner.
http://localhost:6543/FrontPage invokes the
view_page
view of theFrontPage
page object.http://localhost:6543/FrontPage/edit_page invokes the
edit_page
view for theFrontPage
page object. It is executable by only theeditor
user. If a different user invokes it, then the "403 Forbidden" page will be displayed. If an anonymous user invokes it, then a login form will be displayed.http://localhost:6543/add_page/SomePageName invokes the
add_page
view for a page. If the page already exists, then it redirects the user to theedit_page
view for the page object. It is executable by either theeditor
orbasic
user. If an anonymous user invokes it, then a login form will be displayed.http://localhost:6543/SomePageName/edit_page invokes the
edit_page
view for an existing page, or generates an error if the page does not exist. It is editable by thebasic
user if the page was created by that user in the previous step. If instead the page was created by theeditor
user, then the login page should be shown for thebasic
user.