Access control
A feature gates its screens with role_required, hides the links to them with has_role, and a feature with finer rules for another feature’s screens registers an access policy for them.
Table of contents
Roles
The account’s role lives in the role column that splent_feature_auth adds to User. Auth’s vocabulary is admin, staff and user, and a product adds a role by using a new name in its gates and seeders.
The catalog features follow two conventions, the same pair the admin panel uses for its dashboard.
| Constant | Roles | Used for |
|---|---|---|
PANEL_ROLES |
("admin", "staff") |
Content and inbox screens in the panel, such as posts, team members or contact messages |
ADMIN_ROLES |
("admin",) |
The site’s secrets and site-wide switches, such as captcha keys |
Each feature declares the constant it uses next to its routes, so the gate and the sidebar link read the same tuple.
Gate a screen
from splent_framework.decorators.decorators import role_required
# Who manages courses in the panel. The admin dashboard lands the same roles.
PANEL_ROLES = ("admin", "staff")
@courses_bp.route("/admin/courses")
@role_required(*PANEL_ROLES)
def admin_courses():
...
role_required already asks for a login, so it replaces login_required on the view. An anonymous visitor goes to the login page, a signed-in account outside the roles gets 403.
Hide a link
from splent_framework.decorators.decorators import has_role
def courses_sidebar_link():
if not has_role(*PANEL_ROLES, endpoint="courses.admin_courses"):
return ""
return render_template("hooks/sidebar_courses.html")
Pass the endpoint the link opens. Without it has_role reads the roles alone, and a link to a screen some policy opens to other accounts would be missing for them.
An entry of the menu registry needs none of this. role_required leaves the roles it asks for on the view, required_roles(view) reads them, and the menu asks has_role with them and the endpoint for every entry that names no roles of its own.
Access policies
A feature that brings finer rules for another feature’s screens registers a policy for their endpoints. edition_staff lets the staff of an edition into the events and editions admin, although their accounts have the plain user role, and every other panel screen keeps refusing them.
from splent_framework.decorators.decorators import register_access_policy
# The screens this feature has decided, each with its per-edition check.
STAFF_ENDPOINTS = {
"events.admin_index": any_edition_role,
"events.admin_edit": on_the_event("edit_event"),
"editions.admin_edit": on_the_edition("manage_edition"),
}
def staff_of_some_edition(user, endpoint, view_args):
if staff_service.is_admin(user):
return None # the roles decide, before any query
if endpoint not in STAFF_ENDPOINTS:
return None # a screen this feature has not decided
return bool(staff_service.editions_for(user))
def init_feature(app):
register_access_policy("events.admin", staff_of_some_edition)
register_access_policy("editions.admin", staff_of_some_edition)
Three habits keep a policy safe.
- Pass the accounts the roles already handle, first and without a query. A policy that raises refuses, so a failing query would otherwise lock the site admin out.
- Answer only for the screens the feature has decided. A new screen added later stays with its roles until someone decides it.
- Say False to an account the feature knows it must keep out, so the sidebar link disappears for it instead of opening a page that answers 403.
The per-edition checks in STAFF_ENDPOINTS run in a before_request hook of the same feature, which reads the same table, so the policy and the narrowing cannot drift apart.
policy(user, endpoint, view_args) answers for every endpoint whose name starts with the prefix. The prefix starts with the blueprint’s name and a dot (events.admin, or events. for the whole blueprint), so it never covers another blueprint whose name merely starts the same way.
| Answer | Effect |
|---|---|
True |
The account gets in, whatever the screen’s roles |
False |
The account gets 403, whatever its role |
None |
The policy passes. The next shorter prefix decides, or the screen’s roles when none is left |
Register policies from init_feature. The registry is filled at startup and only read while serving.
Special cases
| Case | Behaviour |
|---|---|
| Anonymous visitor | Sent to the login page. No policy is asked |
| Inactive account | 403, even when a policy would answer True |
| Several prefixes match | Asked from the longest to the shortest, and the first answer that is not None decides |
| The same prefix registered twice | The later policy replaces the earlier one, with a warning in the log |
| A prefix without the blueprint and a dot | register_access_policy raises ValueError |
| A policy raises | The request is refused with 403 and the error is logged |
A view with only login_required |
Policies are not asked. Gate it with role_required first |
Two role_required stacked on one view |
Both are asked, so only the roles they share get in, and required_roles(view) returns those |
| Tests of a feature’s own gate | Use the no_access_policies fixture, or with isolated_access_policies():. Policies other features registered at startup come back when the test ends. The registry belongs to the process, not to one app |
See also
- Extension points
- Signals, for vetoing an action instead of a screen