Skip to content

Permissions

Permissions work at four levels: the view, the action, the field and the row. Each is a method on the view, so the answer can depend on who is asking and on the record. The user is on request.state.user, as your auth provider loaded it.

The examples on this page use the example models.

Views and actions

The simplest switches are attributes:

class AuditedPayments(ModelView[Payment]):
    can_create = False
    can_delete = False

For anything that depends on the user, override allows:

from starlette.requests import Request

from adminsite import Permission


class OrderView(ModelView[Order]):
    async def allows(
        self, action: Permission | str, *, request: Request, record: Order | None
    ) -> bool:
        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, VIEW_DETAIL, EXPORT, IMPORT and HISTORY, or the permission an action asks for. record is the record the question is about, or None for the view as a whole. The check runs before a page is shown and again before anything is written, and buttons for what the user may not do are left out.

Roles

With AdminUsers, a superuser ticks on the Roles page what each role may do: View, Add, Edit, Delete, Export, Import and History on each view, View on each page of your own, and each permission an action asks for of its own, such as @action("Mark as shipped", permission="ship"). Each person may do whatever one of their roles allows, and a superuser everything.

The roles come after the view's own settings, so a view with can_delete = False never deletes, and an allows that ends with super().allows(...), as above, keeps both. Rules about rows and fields stay in code, where they can ask about a role:

from starlette.requests import Request

from adminsite import Statement


class OrderView(ModelView[Order]):
    def scope_query(self, statement: Statement, *, request: Request) -> Statement:
        if request.state.user.has_role("Support"):
            return statement.where(Order.status == OrderStatus.PAID)
        return statement

Roles from your own user table

An auth provider of your own can answer the same way: its grants_for returns the Grants a user's roles add up to, each view named as in its URL, and every view and page follows them.

from adminsite import Grants
from adminsite.auth import AuthProvider


class StaffAuth(AuthProvider):
    async def grants_for(self, user: User) -> Grants | None:
        if user.is_staff:
            return Grants(everything=True)
        return Grants({"orders": {"view", "edit"}, "customers": {"view"}})

History

Permission.HISTORY decides who reads a view's audit log: 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[Salary]):
    async def allows(
        self, action: Permission | str, *, request: Request, record: Salary | None
    ) -> bool:
        if action == Permission.HISTORY:
            return request.state.user.is_hr
        return await super().allows(action, request=request, record=record)

The Activity page shows entries only from views where the user has both VIEW and HISTORY.

Rows

scope_query narrows every read the view makes:

from adminsite import Statement


class OrderView(ModelView[Order]):
    def scope_query(self, statement: Statement, *, request: Request) -> Statement:
        return statement.where(Order.region == request.state.user.region)

Statement is the select the view hands over, so return it narrowed. The scope covers the list, its counts, a record's page, the export, bulk actions and the history. A row outside it cannot be seen, changed or deleted, and its URL answers "not found".

Other views follow it too. A picker for a customer reads through CustomerView, so it offers only the customers this user may see, and an order whose customer the scope hides shows that customer as "Hidden".

To keep records off the list while links still reach them, such as deleted ones, use list_query instead.

Fields

can_access_field decides who sees which field, page by page, and get_readonly_fields who may change it:

from starlette.requests import Request

from adminsite import BaseField, ColumnReference, RequestAction


class CustomerView(ModelView[Customer]):
    fields = [Customer.name, Customer.email, Customer.credit_limit]

    def can_access_field(
        self, request: Request, field: BaseField, action: RequestAction
    ) -> bool:
        if field.name == "credit_limit":
            return request.state.user.can_see_money
        return True

    def get_readonly_fields(
        self, request: Request, record: Customer | None
    ) -> list[ColumnReference]:
        if not request.state.user.is_manager:
            return [Customer.credit_limit]
        return []

action is the page the field is asked for: RequestAction.LIST, DETAIL, CREATE, EDIT or EXPORT, so action == RequestAction.EXPORT keeps a column out of the CSV alone. field.name is the path the field shows, such as credit_limit or customer.email.

A field refused on a page is left off it. A field refused on the list is also left out of the search and the filters, so nobody finds records by a value kept from them. A filter of your own that reads such a field is not covered, so leave it out in get_list_filters.

get_readonly_fields answers for one record, and record is None on the form for a new one. A locked or refused field is ignored when the form comes back, even if someone adds the input back.

Inline fields

The fields of an inline are decided by the view it belongs to. can_access_field gets each one by its path from that view, such as items.unit_price, and get_readonly_fields locks one with a Link:

class OrderView(ModelView[Order]):
    inlines = [
        Inline(
            Order.items,
            fields=[OrderItem.product, OrderItem.quantity, OrderItem.unit_price],
        )
    ]

    def can_access_field(
        self, request: Request, field: BaseField, action: RequestAction
    ) -> bool:
        if field.name == "items.unit_price":
            return request.state.user.can_see_money
        return True

    def get_readonly_fields(
        self, request: Request, record: Order | None
    ) -> list[ColumnReference]:
        if record is not None and record.status == "shipped":
            return [Link(Order.items, OrderItem.quantity)]
        return []

action is RequestAction.CREATE for a new row, EDIT for an existing one and DETAIL on the record page. record is the parent, so the lines of a shipped order are locked together.