Authorization, ACLs & Permissions

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Pyramid 2.1 (the 2.1-branch documentation and the 2.1 release installed from PyPI) on Python 3.13, checked on 2026-10-07; Pyramid 2.1 itself supports Python 3.10 and newer. Every Python example was executed with those versions and exercised through webtest.TestApp. The running example is the Bookshelf Shelves scenario: public and private shelves owned by users, each holding books, with a group of administrators who can do everything except what a rule explicitly forbids.

Authentication answers "who is making this request?"; authorization answers "may that caller do this to this object?". Pyramid 2.x splits the two cleanly. Authentication produces an identity and lives in the security policy (the next page of the section, security policy and authentication, covers writing one in depth). Authorization is the permits method of that same policy, which receives the request, the context resource and a permission name and returns an Allowed or a Denied object. Pyramid ships no policy of its own, but it ships the most common implementation of permits as a helper: ACLHelper, which reads an access control list (ACL) from the context and, if the context has none, from its parents. This page covers both layers: the declarative part (permissions on views, the default permission, ACLs, inheritance, route factories) and the programmatic part (imperative checks, debug output and a permits that does not use ACLs at all). Flask has no counterpart in the core (Flask Authorization & Roles relies on extensions such as Flask-Login for identity and on decorators for roles), Django has a global permission table checked with user.has_perm() (see Django: Introduction & Architecture), and FastAPI uses dependencies (see FastAPI dependency injection); Pyramid’s distinctive idea is that the permission is declared on the view while the rule lives on the object.

How a Permission Check Happens

The security system is optional and declarative. Nothing is protected until two things are true: a security policy is installed, and the view that is about to run carries a permission. Before the view callable is invoked, a view deriver wraps it in a check that calls permits(request, context, permission) on the policy. If the result is false the wrapper raises HTTPForbidden, which the exception-view machinery turns into the forbidden view (Errors & Exception Views covers the machinery).

flowchart TD A["Router finds the context and the best matching view"] --> B{"View registered with a permission?"} B -- "no" --> F["Call the view callable"] B -- "yes" --> C["policy.permits(request, context, permission)"] C --> D{"Result is truthy?"} D -- "Allowed or ACLAllowed" --> F D -- "Denied or ACLDenied" --> E["HTTPForbidden: the forbidden view runs"]
Figure 1. A view with a permission: the check runs after view lookup and before the view callable

Two consequences are worth stating now, because they drive most real mistakes. A view without a permission is open to everyone and the policy is not even consulted. And the permission is checked against the context that the router found, so the same view and permission can be allowed for one shelf and denied for another.

The Bookshelf Shelves Resource Tree

ACL inheritance needs a tree of location-aware resources: objects with __name__ and __parent__, as explained in Traversal & Resources. The model below is deliberately small and in memory (the data layer is the subject of the SQLAlchemy page). It builds a root, a shelves container, a public shelf owned by ada with two books, and a private shelf owned by grace. Each level declares its own rules, and the rules only mention principals and permissions; they never mention URLs or views.

from pyramid.authorization import (
    ALL_PERMISSIONS,
    DENY_ALL,
    Allow,
    Authenticated,
    Deny,
    Everyone,
)


class Node:
    """Location-aware: __name__ and __parent__ make ACL inheritance work."""

    __name__ = ""
    __parent__ = None

    def __init__(self, name, parent=None):
        self.__name__ = name
        self.__parent__ = parent


class Container(Node):
    def __init__(self, name, parent=None):
        super().__init__(name, parent)
        self.children = {}

    def __getitem__(self, name):
        return self.children[name]

    def add(self, child):
        child.__parent__ = self
        self.children[child.__name__] = child
        return child


class Book(Node):
    def __init__(self, name, title, pinned=False):
        super().__init__(name)
        self.title = title
        self.pinned = pinned

    def __acl__(self):
        # An empty list expresses "no opinion": the parents are consulted.
        return [(Deny, Everyone, "delete")] if self.pinned else []


class Shelf(Container):
    def __init__(self, name, owner, private=False):
        super().__init__(name)
        self.owner = owner
        self.private = private

    def __acl__(self):
        owner = f"user:{self.owner}"
        if self.private:
            return [
                (Allow, "group:admins", ALL_PERMISSIONS),
                (Allow, owner, ALL_PERMISSIONS),
                DENY_ALL,
            ]
        return [(Allow, owner, ("add", "edit", "delete"))]


class Shelves(Container):
    __acl__ = [(Allow, Authenticated, "create")]


class Root(Container):
    __acl__ = [
        (Allow, Everyone, "view"),
        (Allow, "group:admins", ALL_PERMISSIONS),
    ]

    def __init__(self, request=None):
        super().__init__("")
        shelves = self.add(Shelves("shelves"))
        ada = shelves.add(Shelf("ada", "ada"))
        ada.add(Book("dune", "Dune"))
        ada.add(Book("emma", "Emma", pinned=True))
        grace = shelves.add(Shelf("grace", "grace", private=True))
        grace.add(Book("cobol", "Notes on COBOL"))


library = Root()
print(sorted(library["shelves"].children))
print(library["shelves"]["ada"]["dune"].__parent__.__name__)

Root(request=None) doubles as the root factory (Pyramid calls it with the request), so each request gets a fresh tree; a real application would load the objects from a database instead (see the data pages of this section).

A Minimal Security Policy for the Examples

Pyramid asks a security policy for four things plus permits: identity, authenticated_userid, remember and forget. For the examples here the identity is taken from the WSGI REMOTE_USER key, which is how a front-end web server (Apache, nginx with an auth module) can pass an already-authenticated user name to the application and which webtest can set per request. A real application uses one of the helpers (session or auth-ticket cookie); that is the subject of the security policy page. The authorization half is the important part: build the list of principals for the caller and hand it to ACLHelper.permits.

from pyramid.authorization import ACLHelper

USERS = {"ada": [], "grace": [], "root": ["admins"]}  # userid -> groups


def principals_for(userid):
    """Everyone, Authenticated, the user itself and each group, namespaced."""
    principals = [Everyone]
    if userid is not None:
        principals += [Authenticated, f"user:{userid}"]
        principals += [f"group:{group}" for group in USERS[userid]]
    return principals


class BookshelfPolicy:
    def identity(self, request):
        userid = request.environ.get("REMOTE_USER")
        return userid if userid in USERS else None

    def authenticated_userid(self, request):
        return self.identity(request)

    def permits(self, request, context, permission):
        principals = principals_for(self.identity(request))
        return ACLHelper().permits(context, principals, permission)

    def remember(self, request, userid, **kw):
        return []

    def forget(self, request, **kw):
        return []


print(principals_for("root"))

The user: and group: prefixes are a convention, not a Pyramid rule, but they matter: without them a user whose id happens to be admins would be indistinguishable from the group of the same name (see the pitfalls below). Everyone and Authenticated are the strings system.Everyone and system.Authenticated; they are the only principals Pyramid itself defines.

Protecting Views with Permissions

A permission is a plain string: Pyramid does not predefine any, and "view", "edit" or "bookshelf:delete" are equally valid. You attach one to a view with the permission argument of add_view (or of @view_config), and the same view can be registered several times with different permissions for different contexts or request methods.

from pyramid.config import Configurator
from pyramid.httpexceptions import HTTPNotFound
from webtest import TestApp


def home(request):
    return {"shelves": sorted(request.context["shelves"].children)}


def shelf_view(request):
    shelf = request.context
    return {"shelf": shelf.__name__, "books": sorted(shelf.children)}


def book_view(request):
    return {"title": request.context.title}


def edit_shelf(request):
    return {"editing": request.context.__name__}


def delete_book(request):
    book = request.context
    del book.__parent__.children[book.__name__]
    return {"deleted": book.__name__}


def make_config(policy=None, **settings):
    config = Configurator(settings=settings, root_factory=Root)
    config.set_security_policy(policy or BookshelfPolicy())
    config.add_view(home, context=Root, permission="view", renderer="json")
    config.add_view(shelf_view, context=Shelf, permission="view", renderer="json")
    config.add_view(book_view, context=Book, permission="view", renderer="json")
    config.add_view(edit_shelf, context=Shelf, name="edit", permission="edit", renderer="json")
    config.add_view(
        delete_book,
        context=Book,
        name="delete",
        request_method="POST",
        permission="delete",
        renderer="json",
    )
    return config


def status(app, method, path, user=None):
    environ = {"REMOTE_USER": user} if user else {}
    response = getattr(app, method.lower())(path, extra_environ=environ, status="*")
    return response.status_int


app = TestApp(make_config().make_wsgi_app())

assert status(app, "GET", "/") == 200
assert status(app, "GET", "/shelves/ada") == 200
assert status(app, "GET", "/shelves/ada/edit") == 403  # anonymous
assert status(app, "GET", "/shelves/ada/edit", "ada") == 200  # owner
assert status(app, "GET", "/shelves/ada/edit", "grace") == 403  # somebody else
assert status(app, "GET", "/shelves/ada/edit", "root") == 200  # admins group
assert status(app, "POST", "/shelves/ada/dune/delete", "ada") == 200
print("view permissions behave as the ACLs say")

The same registration with the decorator reads as below; the decorator form only takes effect when config.scan() visits the module (see Configuration & the Configurator).

# norun
from pyramid.view import view_config


@view_config(context=Shelf, name="edit", permission="edit", renderer="json")
def edit_shelf(request):
    return {"editing": request.context.__name__}

A denied request raises HTTPForbidden (status 403). Because the check happens after traversal and view lookup, two different failures are distinguishable: no matching view gives 404 and a matching but forbidden view gives 403. A permission that no ACL ever grants is not an error: it simply denies everyone who is not covered by an ALL_PERMISSIONS entry, which is why a typo such as permission="eddit" fails closed, whereas a missing permission fails open.

Where the permission is set Effect

permission="edit" on add_view / @view_config

The policy is asked permits(request, context, "edit") before the view runs.

No permission argument, no default permission

The view is public; the policy is never consulted.

permission on a method of a view class (@view_config on the method, or attr in add_view)

That one method is protected; each method of the class needs its own registration and permission.

permission=NO_PERMISSION_REQUIRED

Public even when a default permission is configured (next section).

add_static_view(..., permission=...)

Static assets are served without a permission unless you pass one; a default permission does not apply to them (see Static Assets).

Default Permission: Secure by Default

Forgetting a permission on a single view silently opens it to the world. Pyramid offers the inverse policy: config.set_default_permission(name) (or the default_permission argument of the Configurator) makes every view registration that does not name a permission use the default. A registration that names its own permission keeps it, and a registration that names NO_PERMISSION_REQUIRED opts out. Only one default can be active, and a second call conflicts with the first.

import tempfile
from pathlib import Path

from pyramid.response import Response
from pyramid.security import NO_PERMISSION_REQUIRED


def stats(request):
    return {"shelves": len(request.context["shelves"].children)}


def health(request):
    return {"ok": True}


def login_page(request):
    return {"login": "form goes here"}


def forbidden(request):
    return Response("Not allowed.", status=403, content_type="text/plain")


def not_found(request):
    return Response("No such page.", status=404, content_type="text/plain")


assets = Path(tempfile.mkdtemp())
(assets / "logo.txt").write_text("logo")

config = make_config()
config.set_default_permission("manage")  # a permission only admins hold
config.add_view(stats, context=Root, name="stats", renderer="json")  # inherits "manage"
config.add_view(health, context=Root, name="health", renderer="json", permission=NO_PERMISSION_REQUIRED)
config.add_view(login_page, context=Root, name="login", renderer="json")  # forgotten opt-out
config.add_forbidden_view(forbidden)  # these two directives never use a permission
config.add_notfound_view(not_found)
config.add_static_view("assets", str(assets))  # permissive unless permission= is given
secure_app = TestApp(config.make_wsgi_app())

assert status(secure_app, "GET", "/stats") == 403  # anonymous
assert status(secure_app, "GET", "/stats", "ada") == 403  # authenticated but not an admin
assert status(secure_app, "GET", "/stats", "root") == 200  # group:admins holds ALL_PERMISSIONS
assert status(secure_app, "GET", "/health") == 200  # explicit opt-out
assert status(secure_app, "GET", "/login") == 403  # the classic mistake: nobody can reach the login view
assert status(secure_app, "GET", "/shelves/ada") == 200  # explicit "view" is kept
assert secure_app.get("/nowhere", status=404).text == "No such page."
assert status(secure_app, "GET", "/assets/logo.txt") == 200
print("default permission applied, opt-outs honoured")

The default applies to every add_view / @view_config registration that names no permission, wherever in the configuration the call to set_default_permission is placed (it is resolved in the first configuration phase). The official documentation warns that even exception views are then protected. In the 2.1 code that warning applies to views you register yourself with add_view; the dedicated directives add_forbidden_view, add_notfound_view and add_exception_view register their views with NO_PERMISSION_REQUIRED and raise a ConfigurationError if you pass a permission to them, and add_static_view is permissive unless you give it a permission. What a default permission does break is a login page, a registration page, a health check or a JSON "who am I" endpoint registered with add_view: as the example shows, an anonymous visitor then gets 403 on the login page. Mark each of them with NO_PERMISSION_REQUIRED. A default permission is the recommended starting point for any application with a login; the permission that public pages need (here "view") is then requested explicitly.

Writing ACLs

An ACL is an ordered sequence of access control entries (ACEs). Each ACE is a three-element tuple: an action, a principal and a permission.

Element Meaning

Action

Allow or Deny (importable from pyramid.authorization; they are the strings Allow and Deny).

Principal

A string that identifies a user or a group, such as user:ada or group:admins, or one of the special names Everyone and Authenticated.

Permission

One permission name, or a sequence of names (("add", "edit")), or ALL_PERMISSIONS.

The special names live in pyramid.authorization:

Name Value Meaning

Everyone

system.Everyone

Every caller, authenticated or not. It is part of the principal list your policy builds; nothing adds it automatically for you inside permits.

Authenticated

system.Authenticated

Any caller your policy treats as logged in.

ALL_PERMISSIONS

an AllPermissionsList object

Stands for every permission: its __contains__ always answers true.

DENY_ALL

(Deny, Everyone, ALL_PERMISSIONS)

A ready-made ACE, normally the last entry of an ACL, that stops the inheritance walk.

from pyramid.authorization import ACLHelper

helper = ACLHelper()
shelf = library["shelves"]["ada"]

print(repr(Everyone), repr(Authenticated), repr(Allow), repr(Deny))
print(DENY_ALL[0], DENY_ALL[1], type(DENY_ALL[2]).__name__)

# A sequence of permissions is one ACE; "delete" is in it, "create" is not.
ace = (Allow, "user:ada", ("add", "edit", "delete"))
assert helper.permits(shelf, ["user:ada"], "delete")
assert not helper.permits(shelf, ["user:ada"], "create")
print(helper.permits(shelf, ["user:ada"], "edit").msg[:48])

ACLHelper.permits(context, principals, permission) returns an ACLAllowed or an ACLDenied. Both evaluate to true and false respectively, and both carry a msg attribute (and ace, acl, permission, principals and context) that says which entry decided. Prefer Everyone and Authenticated from pyramid.authorization; the same names in pyramid.security are deprecated aliases kept from Pyramid 1.x.

First Match Wins

An ACL is a program, not a set: the helper reads the entries in order and the first ACE whose principal is among the caller’s principals and whose permission matches decides the outcome, Allow or Deny. Entries after it are never read. The same two entries therefore mean opposite things in opposite orders:

class Probe:
    __parent__ = None
    __name__ = ""

    def __init__(self, acl):
        self.__acl__ = acl


allow_first = Probe([(Allow, Everyone, "view"), (Deny, Everyone, "view")])
deny_first = Probe([(Deny, Everyone, "view"), (Allow, Everyone, "view")])

assert helper.permits(allow_first, [Everyone], "view")
assert not helper.permits(deny_first, [Everyone], "view")

# The principals list is order-insensitive, but each ACE is tested in ACL order:
# the broad Deny for Authenticated wins over the later, narrower Allow for ada.
mixed = Probe([(Deny, Authenticated, "edit"), (Allow, "user:ada", "edit")])
assert not helper.permits(mixed, principals_for("ada"), "edit")
print("first match wins")

Two idioms follow. Put the narrow, specific entries first and the broad ones last. And end an ACL that must be final with DENY_ALL, because the default result for "no entry matched anywhere" is ACLDenied but the walk would otherwise continue to the parents, which may grant the permission.

Inheritance Through __parent__

When the context has no ACL, or has one that neither allows nor denies the permission, the helper moves to the context’s parent, then to the parent’s parent, up to the root. That walk is the lineage of the context (pyramid.location.lineage). It needs location awareness: every object has __parent__ (and __name__), the root’s parent being None. An object without __parent__ ends the lineage, which silently turns "inherit from the root" into "no rules at all".

ACL inheritance down the shelf tree: a book inherits from its shelf, the shelves container and the root; a private shelf ends the walk with DENY_ALL
Figure 2. ACL inheritance down the Bookshelf Shelves tree: the lookup walks up from the context and stops at DENY_ALL

The model above uses all three possibilities. A book on ada’s public shelf has no entries of its own, so view comes from the root’s Allow Everyone, and edit from the shelf’s owner entry. The pinned book emma adds one Deny, which stops even the administrators from deleting it. grace’s private shelf is cut off from the root’s Allow Everyone entry by DENY_ALL, and so its ACL names group:admins itself, before the DENY_ALL.

ada = library["shelves"]["ada"]
dune, emma = ada["dune"], ada["emma"]
grace_shelf = library["shelves"]["grace"]
cobol = grace_shelf["cobol"]

# Book without ACL inherits from shelf, container and root.
assert helper.permits(dune, principals_for(None), "view")  # root: Everyone view
assert helper.permits(dune, principals_for("ada"), "edit")  # shelf: owner
assert not helper.permits(dune, principals_for("grace"), "edit")
assert helper.permits(dune, principals_for("root"), "delete")  # root: admins

# A Deny on the book beats the root's admin Allow (the book is read first).
assert not helper.permits(emma, principals_for("root"), "delete")
assert not helper.permits(emma, principals_for("ada"), "delete")

# DENY_ALL on grace's shelf hides the root's Allow Everyone view.
assert not helper.permits(cobol, principals_for(None), "view")
assert not helper.permits(cobol, principals_for("ada"), "view")
assert helper.permits(cobol, principals_for("grace"), "view")
assert helper.permits(cobol, principals_for("root"), "view")  # named before DENY_ALL

# The Shelves container grants "create" to Authenticated, below the root.
assert helper.permits(grace_shelf.__parent__, principals_for("ada"), "create")
assert not helper.permits(grace_shelf.__parent__, principals_for(None), "create")
print("inheritance and DENY_ALL verified")

Callable ACLs, and Why Not Properties

An __acl__ may be a list or a callable that returns one. A callable is invoked without arguments each time the helper reads it, so it can look at the instance (the Shelf ACL above uses self.owner and self.private). It may return an empty list to say "no opinion". Writing __acl__ as a Python property is discouraged by the official documentation: if the getter raises AttributeError, Python’s getattr machinery swallows it and the helper simply sees "no ACL", which grants nothing and so hides the bug. Use a method.

class Report:
    __parent__ = None
    __name__ = ""

    def __init__(self, owner):
        self.owner = owner

    @property
    def __acl__(self):  # discouraged: the typo below is silently swallowed
        return [(Allow, self.owner_id, "view")]  # AttributeError


report = Report("ada")
assert getattr(report, "__acl__", None) is None  # the helper behaves the same way
assert not helper.permits(report, [Everyone], "view")
print("property ACL: the error disappeared, access was denied")

Who Holds a Permission

ACLHelper.principals_allowed_by_permission(context, permission) walks the lineage from the root down to the context and returns the set of principals that are allowed: an Allow adds a principal, a later Deny for the same principal removes it, and a Deny for Everyone clears everything granted above it. It is useful for notifications ("who can edit this shelf?") and for audit scripts, but it only understands ACLs: a permits that decides in code is invisible to it.

print(sorted(helper.principals_allowed_by_permission(ada, "edit")))
print(sorted(helper.principals_allowed_by_permission(grace_shelf, "view")))

The module-level function pyramid.security.principals_allowed_by_permission is the legacy form; it works only with the legacy authorization policy interface and not with a security policy.

Per-Object ACLs: Traversal and Route Factories

The ACL always belongs to the context, so the real question is how the context is produced. Under traversal the URL walks the tree and the object where the walk ends is the context; the examples above work that way, with Root as root_factory. Under URL dispatch there is no tree by default and the context is a bare default root object with no ACL, so nothing is ever allowed by an ACL: the route’s factory decides the context. A route factory is any callable that receives the request and returns the context (a class whose constructor takes the request works too); request.matchdict is already filled in. Returning a location-aware object from the tree gives the same inheritance as traversal.

from pyramid.security import Allowed


def shelf_factory(request):
    try:
        return library["shelves"][request.matchdict["shelf"]]
    except KeyError:
        raise HTTPNotFound()


def api_shelf(request):
    shelf = request.context
    return {"shelf": shelf.__name__, "owner": shelf.owner, "books": sorted(shelf.children)}


def api_add_book(request):
    shelf = request.context
    name = request.json_body["name"]
    shelf.add(Book(name, request.json_body["title"]))
    return {"added": name}


config = Configurator(settings={}, root_factory=Root)
config.set_security_policy(BookshelfPolicy())
config.add_route("api_shelf", "/api/shelves/{shelf}", factory=shelf_factory)
config.add_view(api_shelf, route_name="api_shelf", request_method="GET", permission="view", renderer="json")
config.add_view(api_add_book, route_name="api_shelf", request_method="POST", permission="add", renderer="json")
api = TestApp(config.make_wsgi_app())


def api_status(method, path, user=None, **kw):
    environ = {"REMOTE_USER": user} if user else {}
    call = getattr(api, method.lower())
    return call(path, extra_environ=environ, status="*", **kw).status_int


assert api_status("GET", "/api/shelves/ada") == 200  # Everyone view, from the root ACL
assert api_status("GET", "/api/shelves/grace") == 403  # DENY_ALL on the private shelf
assert api_status("GET", "/api/shelves/grace", "grace") == 200
assert api_status("GET", "/api/shelves/nowhere") == 404  # the factory raised HTTPNotFound
body = {"name": "ulysses", "title": "Ulysses"}
assert api_status("POST", "/api/shelves/ada", params=body, content_type="application/json") == 403
assert api_status("POST", "/api/shelves/ada", "grace", params=__import__("json").dumps(body), content_type="application/json") == 403
assert api_status("POST", "/api/shelves/ada", "ada", params=__import__("json").dumps(body), content_type="application/json") == 200
print(sorted(library["shelves"]["ada"].children))

The factory runs before the permission check, so a missing object produces 404 for everybody, including anonymous callers, while an existing private object produces 403. If the mere existence of a private shelf is itself information, make the factory answer 404 for shelves the caller may not see, or have the forbidden view answer 404 for those routes. The route-factory mechanism in general, including the basic ACL setup for URL dispatch, is covered by URL Dispatch.

A route factory can also return an object that is not part of any tree. Such a context has only its own ACL (nothing is inherited, because it has no __parent__), which is often exactly right for one-off resources such as a user’s own profile.

class Profile:
    """Standalone, per-request context: no parent, therefore no inheritance."""

    def __init__(self, request):
        self.userid = request.environ.get("REMOTE_USER")

    @property
    def __acl__(self):  # fine here: no attribute lookup can fail
        return [(Allow, f"user:{self.userid}", ("view", "edit")), DENY_ALL]


config = Configurator(root_factory=Root)
config.set_security_policy(BookshelfPolicy())
config.add_route("profile", "/profile", factory=Profile)
config.add_view(lambda request: {"user": request.context.userid}, route_name="profile", permission="view", renderer="json")
profile_app = TestApp(config.make_wsgi_app())

assert profile_app.get("/profile", status="*").status_int == 403  # anonymous: "user:None" is not a principal
assert profile_app.get("/profile", extra_environ={"REMOTE_USER": "ada"}).json == {"user": "ada"}
print("standalone contexts work, with their own ACL only")

Because Pyramid also supports hybrid applications, a route can combine both: a route registered with a factory and a traverse pattern such as /{shelf} runs the route first, then traverses from the factory’s root with the given path, and the context found by traversal supplies the ACL (see Traversal & Resources, the hybrid section).

Imperative Checks in Code and Templates

Declarative permissions protect whole views. Sometimes a view needs a finer decision: show or hide an edit button, or return a shelf’s data with extra fields for an owner. request.has_permission(permission, context=None) asks the installed policy the same question for the current context (or for the context you pass). The result is the same Allowed/Denied (or ACLAllowed/ACLDenied) object as before: usable as a boolean, and with a msg that says why. pyramid.security.view_execution_permitted(context, request, name="") goes one step further and answers "would the view called name run for this context?", using the permission that is registered on that view; it raises TypeError if no registered view matches.

from pyramid.security import view_execution_permitted


def shelf_summary(request):
    shelf = request.context
    can_edit = request.has_permission("edit")
    return {
        "shelf": shelf.__name__,
        "can_edit": bool(can_edit),
        "can_delete_dune": bool(request.has_permission("delete", shelf.children.get("dune", shelf))),
        "edit_view_would_run": bool(view_execution_permitted(shelf, request, "edit")),
        "reason": can_edit.msg.split(" via ")[0],
    }


config = make_config()
config.add_view(shelf_summary, context=Shelf, name="summary", permission="view", renderer="json")
summary_app = TestApp(config.make_wsgi_app())

anonymous = summary_app.get("/shelves/ada/summary").json
owner = summary_app.get("/shelves/ada/summary", extra_environ={"REMOTE_USER": "ada"}).json
assert anonymous["can_edit"] is False and owner["can_edit"] is True
assert owner["can_delete_dune"] is True and owner["edit_view_would_run"] is True
print(anonymous["reason"], "|", owner["reason"])

In a Jinja2 or Chameleon template the same call hides controls that the caller could not use anyway. This is a convenience for the user interface only: the corresponding views must still carry their own permission, because a hidden button does not stop a hand-written request (see Templates).

{% if request.has_permission("edit") %}
  <a href="{{ request.resource_url(context, 'edit') }}">Edit this shelf</a>
{% endif %}

The Forbidden View

When a permission check fails, Pyramid raises HTTPForbidden. Out of the box the response is a plain 403 page. The add_forbidden_view directive (or @forbidden_view_config) registers your own view for that exception: the usual pattern is to send anonymous callers to a login page, and to show a real 403 to callers who are signed in but not allowed.

from urllib.parse import urlencode

from pyramid.httpexceptions import HTTPSeeOther


def forbidden_or_login(request):
    if request.authenticated_userid is None:
        query = urlencode({"next": request.path})
        return HTTPSeeOther(location=f"{request.application_url}/login?{query}")
    return Response("You are signed in but not allowed to do that.", status=403, content_type="text/plain")


config = make_config()
config.add_forbidden_view(forbidden_or_login)
login_app = TestApp(config.make_wsgi_app())

redirect = login_app.get("/shelves/ada/edit", status=303)
print(redirect.headers["Location"])
denied = login_app.get("/shelves/ada/edit", extra_environ={"REMOTE_USER": "grace"}, status=403)
print(denied.text)

The login view must never redirect to an arbitrary next value (an open redirect); accept only paths on your own site. Do not answer unauthorized POST requests with a redirect that drops the body without telling the client what happened. API clients prefer a 401 or 403 with a JSON body, so register a forbidden view per route (route_name) or use accept and request_method predicates in the same way as for any other view.

Debugging Authorization

When a view is allowed or denied unexpectedly, turn on authorization debugging. The setting is debug_authorization (pyramid.debug_authorization = true in the .ini file, or the PYRAMID_DEBUG_AUTHORIZATION=1 environment variable): every check made during view rendering is logged, naming the ACE and the ACL that decided, and the body of a 403 response contains the same text. It is a development tool and should never be enabled in production, because the response body would disclose the ACLs and the principals of the caller. The settings table of the settings page lists every debug flag.

import logging

records = []


class Capture(logging.Handler):
    def emit(self, record):
        records.append(record.getMessage())


logging.getLogger().addHandler(Capture())
logging.getLogger().setLevel(logging.DEBUG)

debug_app = TestApp(make_config(**{"pyramid.debug_authorization": "true"}).make_wsgi_app())
response = debug_app.get("/shelves/ada/edit", status=403)
assert "debug_authorization of url" in response.text
assert any("ACLDenied" in line for line in records)
print(response.text.strip().splitlines()[-1][:150])

For imperative checks (has_permission) there is no debug flag; print or log the msg of the returned object as the earlier example does. A message such as ACLDenied permission "edit" via ACE "<default deny>" means that no entry matched anywhere in the lineage (the usual cause is a principal your policy did not generate, such as a missing group: prefix), whereas a message naming an ACE tells you exactly which entry blocked the request.

Authorization Without ACLs

ACLs are one way to implement permits, not the only one. The method receives the request, the context and the permission; it can use the identity directly, call a service, or consult a role table. A policy that answers Allowed or Denied with a message is fully equivalent from the point of view of views, the forbidden view, has_permission and debug logging (only principals_allowed_by_permission has no meaning). Rules that depend on runtime state ("the owner may edit until the book is lent out", "editors only during business hours") are easier to write as code than as an ACL.

The policy below grants permissions by role, adds an ownership rule, and returns reasons. The owner is found by walking the lineage, so it works for a shelf or any book on it.

from pyramid.location import lineage

from pyramid.security import Allowed, Denied

ROLES = {
    "ada": "editor",
    "grace": "reader",
    "root": "admin",
}
ROLE_PERMISSIONS = {
    "reader": {"view"},
    "editor": {"view", "add", "edit"},
    "admin": {"view", "add", "edit", "delete", "create"},
}


class RolePolicy(BookshelfPolicy):
    def permits(self, request, context, permission):
        if permission == "view":
            return Allowed("Everybody may view")
        userid = self.identity(request)
        if userid is None:
            return Denied("Anonymous callers may only view")
        owner = next((o.owner for o in lineage(context) if hasattr(o, "owner")), None)
        if owner == userid:
            return Allowed("%s owns the shelf", userid)
        if permission in ROLE_PERMISSIONS[ROLES[userid]]:
            return Allowed("%s may %s as %s", userid, permission, ROLES[userid])
        return Denied("%s may not %s as %s", userid, permission, ROLES[userid])


config = make_config(RolePolicy())  # instead of the ACL-based policy
role_app = TestApp(config.make_wsgi_app())

assert status(role_app, "GET", "/shelves/grace") == 200  # no private-shelf rule in this policy
assert status(role_app, "GET", "/shelves/ada/edit", "ada") == 200  # owner
assert status(role_app, "GET", "/shelves/grace/edit", "ada") == 200  # editor role
assert status(role_app, "GET", "/shelves/ada/edit", "grace") == 403  # reader role
assert status(role_app, "POST", "/shelves/ada/dune/delete", "ada") == 200  # owner of the lineage
assert status(role_app, "POST", "/shelves/ada/dune/delete", "root") == 200  # admin role
print("role and ownership rules in a custom permits")

Allowed and Denied live in pyramid.security. Their constructor takes a format string plus arguments, and the formatted text becomes msg. Pass user-controlled strings as arguments rather than concatenating them into the format string.

A pragmatic split is to keep the coarse rules in ACLs, where they are visible and testable as data, and to put only the exceptional dynamic rules in permits before delegating to ACLHelper: for example, return Denied early when the account is suspended, and otherwise return ACLHelper().permits(...).

Legacy Authentication and Authorization Policies

Before Pyramid 2.0 two objects cooperated: an authentication policy (AuthTktAuthenticationPolicy, SessionAuthenticationPolicy, BasicAuthAuthenticationPolicy, RemoteUserAuthenticationPolicy) that produced the user id and a list of effective principals through a callback, and an authorization policy (ACLAuthorizationPolicy) that matched those principals against ACLs. They are installed with set_authentication_policy and set_authorization_policy. In 2.x they are deprecated but still work; the 2.0 documentation says support may be removed in a later release, and the what’s-new page promises that the ACL features themselves are not going away, only moving to pyramid.authorization.

Legacy (1.x style, deprecated) Pyramid 2.x equivalent

set_authentication_policy and set_authorization_policy

One set_security_policy (or the security_policy argument of Configurator)

ACLAuthorizationPolicy

Your permits calling ACLHelper().permits(context, principals, permission)

callback returning group principals

Your own lookup of groups from the identity, inside permits

request.unauthenticated_userid, request.effective_principals

request.identity, request.authenticated_userid, request.is_authenticated; principals are derived inside permits

AuthTktAuthenticationPolicy and SessionAuthenticationPolicy

AuthTktCookieHelper, SessionAuthenticationHelper used inside the policy

effective_principals view and route predicates

Deprecated; use a permission, or a custom predicate that calls request.has_permission

from pyramid.security import Everyone, Authenticated, ALL_PERMISSIONS, DENY_ALL, ACLAllowed, ACLDenied

The same names imported from pyramid.authorization

pyramid.security.principals_allowed_by_permission

ACLHelper().principals_allowed_by_permission(context, permission)

import secrets
import warnings

from pyramid.authentication import AuthTktAuthenticationPolicy
from pyramid.authorization import ACLAuthorizationPolicy

with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    legacy = Configurator()
    legacy.set_authentication_policy(AuthTktAuthenticationPolicy(secrets.token_hex(32)))
    legacy.set_authorization_policy(ACLAuthorizationPolicy())
    legacy.commit()

print(sorted({w.category.__name__ for w in caught}))
assert any(issubclass(w.category, DeprecationWarning) for w in caught)

Do not mix the two systems in a new application. Old articles and books describing ACLAuthorizationPolicy and groupfinder callbacks still show a working ACL model, but the code needs to be rewritten as above.

Testing Authorization

Authorization rules are data plus a small function, so they test well at three levels. ACL tests call ACLHelper directly with a principal list and need no WSGI at all (as most examples on this page do). Policy tests construct the policy and call permits with a fake request. Integration tests drive the whole application with webtest.TestApp and assert on status codes, which is the only level that also proves the view carries the permission you meant.

from pyramid.testing import DummyRequest


def test_policy_denies_anonymous_edit():
    request = DummyRequest(environ={})
    result = BookshelfPolicy().permits(request, library["shelves"]["ada"], "edit")
    assert not result and "ACLDenied" in result.msg


def test_policy_allows_owner_edit():
    request = DummyRequest(environ={"REMOTE_USER": "ada"})
    assert BookshelfPolicy().permits(request, library["shelves"]["ada"], "edit")


def views_relying_on_the_default(wsgi_app):
    """Names of the views registered without an explicit permission."""
    introspector = wsgi_app.registry.introspector
    explicit = {
        related.discriminator
        for item in introspector.get_category("permissions")
        for related in item["related"]
    }
    return sorted(
        item["introspectable"]["name"]
        for item in introspector.get_category("views")
        if item["introspectable"].discriminator not in explicit
        and item["introspectable"]["callable"].__name__ != "default_exceptionresponse_view"
    )


def test_unprotected_views_are_the_expected_ones():
    # Review list: a new name appearing here is a new view without a permission.
    assert views_relying_on_the_default(secure_app.app) == ["login", "stats"]


test_policy_denies_anonymous_edit()
test_policy_allows_owner_edit()
test_unprotected_views_are_the_expected_ones()
print("authorization tests pass")

The last test uses the introspector (see Configuration & the Configurator) to list the views registered without an explicit permission. With a default permission configured those are the views that rely on it (stats, and login, which the earlier example forgot to opt out); without a default they are the public views. Either way it is a short review list that a test can pin, so a new unprotected view cannot appear unnoticed.

Pitfalls and Security

  • Fail open by default. A view without permission is public and the policy is not asked. Configure a default permission and opt out with NO_PERMISSION_REQUIRED.

  • Protecting the route, not the object. A permission on a collection view does not protect /shelves/{id}. Check the permission against the object the user is addressing (the context), or you create an insecure direct object reference, the classic broken-access-control bug.

  • Hiding is not protecting. Removing a link in a template does not secure the view behind it.

  • Principal naming collisions. If user ids and group names share a namespace, a user called admins is an administrator. Namespace principals (user:, group:) and never build principals from unvalidated request data.

  • DENY_ALL stops the walk. Entries inherited from above the ACL carrying it are never read; list the administrators before it, as the private shelf does.

  • Broken location awareness. A resource without __parent__ silently stops inheritance. Test that lineage(resource) ends at the root.

  • Mutable class-level ACLs. A list stored as a class attribute is shared by all instances; mutating it for one object changes all of them. Build the list in a method for per-instance rules.

  • Properties for __acl__. Errors are swallowed and the access is denied silently (see above).

  • Existence leaks. Route factories answer 404 before the permission check can answer 403; decide deliberately which to show.

  • Authorization is not CSRF protection. A permission says who may act, not that the user meant to; unsafe requests still need CSRF defences (the CSRF page of this section).

  • Do not log secrets. debug_authorization output includes principals and ACLs; keep it off in production.

  • Test the denials. A test suite that only checks the allowed paths cannot detect a view that became public.

OWASP lists broken access control first in its Top 10 and keeps an Authorization Cheat Sheet with the same advice: deny by default, enforce on the server for every request, and test the negative cases.

Compared With Other Frameworks

Framework Closest equivalent and the difference

Flask

Decorators such as login_required plus checks inside the view, or Flask-Principal and Flask-Security extensions; the rules are written in code, not declared on the object. See Flask Authorization & Roles.

Django

user.has_perm("app.change_book") and @permission_required, with the permissions stored in the database and checked per model, and object-level permissions only through add-ons. See Django: Introduction & Architecture.

Tornado

@tornado.web.authenticated and get_current_user; everything finer is hand-written in handlers. See Tornado Authentication & Signed Cookies.

FastAPI

Depends(...) and Security(...) with OAuth2 scopes; each dependency does its own check, with no inheritance. See Dependency Injection and OAuth.

Zope and Plone

The origin of the ACL-and-lineage model; Pyramid keeps the ideas without the framework.

Recent Changes and Outdated Material

  • Pyramid 2.0 replaced the authentication/authorization policy pair with a single security policy and moved Everyone, Authenticated, Allow, Deny, ALL_PERMISSIONS, DENY_ALL, ACLAllowed and ACLDenied from pyramid.security to pyramid.authorization. ACLAuthorizationPolicy became ACLHelper; the principal concept was removed from the request API, and request.identity was added. Pyramid 2.1 keeps this design.

  • The 1.x "groupfinder callback" idiom and request.effective_principals belong to the legacy API: they still run, with a DeprecationWarning, but new code derives principals inside permits.

  • Everyone and Authenticated in pyramid.security, and principals_allowed_by_permission as a module function, are deprecated; import from pyramid.authorization and use the helper method.

  • Older tutorials and the wiki and blog sample applications of the 1.x documentation still use groupfinder, ACLAuthorizationPolicy and effective_principals. The concepts (ACL, inheritance, forbidden view) are unchanged; the wiring is not.

  • Pyramid 2.1 is synchronous WSGI: permits runs in the worker thread, so a database lookup there blocks only that thread; cache the identity (and its groups) on the request rather than reading it on every check.

Summary

  • A permission is a string on a view; the policy’s permits(request, context, permission) decides, returning Allowed or Denied. No permission means no check.

  • Set a default permission and mark truly public views, forbidden and not-found views, login and health checks with NO_PERMISSION_REQUIRED.

  • An ACL is an ordered list of (Allow|Deny, principal, permission or sequence); the first matching entry wins; Everyone, Authenticated, ALL_PERMISSIONS and DENY_ALL are in pyramid.authorization.

  • With ACLHelper.permits the context’s ACL is consulted first and then its parents through __parent__; no match anywhere means denied; DENY_ALL stops the walk.

  • Traversal produces the context from the URL; under URL dispatch a route factory does, and it should return location-aware objects to inherit.

  • request.has_permission and view_execution_permitted are the imperative counterparts; debug_authorization shows which ACE decided; permits can also ignore ACLs entirely.

  • The 1.x authentication and authorization policies are deprecated legacy; write a security policy.