Permissions¶
Permissions work at four levels: the whole view, one action, one field, and one row. Each is a method on the view, so the answer can depend on the request and on the record.
The view and its actions¶
The simplest switches are attributes:
class AuditedPayments(ModelView, model=Payment):
can_create = False
can_delete = False
For anything that depends on the user, override allows:
from adminsite import Permission
class OrderView(ModelView, model=Order):
async def allows(self, action, *, request=None, record=None):
user = request.state.user
if action == Permission.DELETE:
return user.is_manager
if action == Permission.EDIT and record is not None:
return record.status != "shipped"
return await super().allows(action, request=request, record=record)
action is one of Permission.VIEW, CREATE, EDIT, DELETE, DETAIL, EXPORT, IMPORT
and HISTORY, or the permission an action asks for. The check runs before a page is shown and again before anything is
written, so a refused user gets an error even if they post the form by hand. Buttons for things the
user may not do are left out.
A refused page shows "Not allowed" inside the admin with a 403, and views a user may not open are left out of the sidebar.
History¶
Permission.HISTORY decides who reads the audit log of a view: the History tab on its
records, and its entries on the Activity page. The log keeps every field's old and new value, so
refuse it where the values are sensitive:
class PayrollView(ModelView, model=Salary):
async def allows(self, action, *, request=None, record=None):
if action == Permission.HISTORY:
return request.state.user.is_hr
return await super().allows(action, request=request, record=record)
The Activity page shows only entries from views where the user has both VIEW and HISTORY, and
only from views registered on this admin, so two admins sharing one log never show each other's
entries. A user with no such view does not see the Activity page at all.
Rows¶
scope_query narrows every read the view makes:
class OrderView(ModelView, model=Order):
def scope_query(self, statement, *, request=None):
return statement.where(Order.region == request.state.user.region)
It is applied to the list, its count, opening one record, the CSV export and bulk actions. A row outside the scope cannot be seen, opened, changed or deleted, and guessing its key in the URL gives a "not found". There is no path through the admin that forgets to check.
That includes a picker on someone else's form. When an order links to a customer, the picker reads
through CustomerView, so it offers only the customers this user may see, and offers none at all
where they may not open the view. See Fields.
The same holds when the form comes back. A key sent for a link is resolved through the target's own view, so a customer outside the user's scope cannot be attached by editing the form, and the answer to a key the view will not give up is the same as to a key that does not exist: "Choose a record." A model with no view of its own is loaded by key, since there is no view to ask.
Sorting follows it too. ?sort= in the URL is honoured only for a column the user can read
somewhere on the view: one on offer in the list, on the record page or in the form. Sorting by
anything else, such as a column left out with exclude, is ignored rather than putting the rows in
the order of a value the user cannot see.
Fields¶
Hide a column or lock a field for some users with the get_ methods:
class CustomerView(ModelView, model=Customer):
list_display = ("name", "email", "credit_limit")
def get_list_display(self, request=None):
if not request.state.user.can_see_money:
return ("name", "email")
return super().get_list_display(request)
def get_readonly_fields(self, request=None, record=None):
if not request.state.user.is_manager:
return ("credit_limit",)
return ()
A locked field is ignored when the form comes back, so adding the input back with the browser's developer tools changes nothing.
Who is asking¶
When signing in is set up, the user is on request.scope["user_record"], as your
auth provider loaded it. If your application already puts the user on request.state
with its own middleware, use that instead; adminsite passes the same request through.