Permissions

Use this guide to enforce Django permissions on reverse relations and craft custom policies beyond the default change checks. Ready-to-run snippets live in Permissions on reverse fields.

Permission modes

Set reverse_permissions_enabled=True to have the mixin evaluate access before rendering or saving a virtual field. reverse_permission_mode controls how denied access surfaces:

  • "disable" (default) — render the field disabled and ignore submitted changes. The mixin also sets required=False on disabled reverse fields so that forms do not raise “This field is required.” when no initial value is present and the browser omits the disabled input from POST data.

  • "hide" — omit the field entirely.

Note

hide removes the input, so any required=True on the virtual field does not apply. Use disable if you need the field to remain visible/read-only while relaxing required semantics.

Use standard admin view guards if you want to block the whole page instead of a single field.

Render-time policies

By default, the render gate only consults a base/global permission to decide visibility and editability. To let per-field (or global) policies influence visibility at render time, set the class flag reverse_render_uses_field_policy = True. In this mode the render gate calls has_reverse_change_permission(request, obj, config, selection=None). Policies must therefore handle selection=None sensibly.

Example:

class CompanyAdmin(ReverseRelationAdminMixin, admin.ModelAdmin):
    reverse_permissions_enabled = True
    reverse_render_uses_field_policy = True
    reverse_relations = {
        "department_binding": ReverseRelationConfig(
            model=Department,
            fk_field="company",
            multiple=False,
            permission=lambda request, obj, config, selection: getattr(request.user, "is_staff", False),
        )
    }

The per-field policy above is evaluated during render with selection=None. If it returns False, the field will be hidden or disabled according to reverse_permission_mode.

Custom policies

Permissions can be supplied globally via has_reverse_change_permission() or per field via ReverseRelationConfig.permission. You may provide policies in three ergonomic shapes with identical semantics (return True to allow, False to deny).

A callable with the signature (request, obj, config, selection) -> bool is ideal for tiny, stateless predicates.

A Policy object implementing __call__ and optionally permission_denied_message is useful for bundling state or reusable messages.

An object exposing has_perm(request, obj, config, selection) -> bool is useful when adapting existing helpers. The permission_denied_message attribute is honoured if present.

Per-field policies override the global method when provided.

Evaluation flow

Permission checks run at three points:

  1. Render gate — decides whether a field is hidden or disabled before templates render. By default, it checks a base permission. If reverse_render_uses_field_policy is True, it consults the full per-field or global policy (with selection=None).

  2. Validation gate — once a selection exists, the per-field policy runs (if defined) and otherwise the global policy is invoked. Denials raise a field error using the precedence below. If no custom policy is configured at all (neither per-field nor global), the mixin does not attach validation errors for base permission denials; instead, the UI gating (hide/disable) applies, and hidden/disabled inputs are ignored on save.

  3. Persistence gate — as a safety net, the mixin excludes unauthorized fields from the update payload so crafted POSTs cannot persist changes. This includes hidden and disabled reverse fields.

Error message precedence

When a policy denies a selection, the field error message resolves in this order:

  1. ReverseRelationConfig.permission_denied_message

  2. permission.permission_denied_message on the per-field policy object

  3. reverse_permission_policy.permission_denied_message on the global policy object

  4. Default "You do not have permission to choose this value."

Permission denied message precedence

Source

Example attribute path

Precedence

Field override

config.permission_denied_message

1 (highest)

Per-field policy object

config.permission.permission_denied_message

2

Global policy object

admin.reverse_permission_policy.permission_denied_message

3

Built-in default

"You do not have permission to choose this value."

4 (lowest)

Visualising the flow

        flowchart TD
  subgraph Render
    A[Start] --> B{reverse_permissions_enabled?};
    B -- No --> RenderNormal[Render normally];
    B -- Yes --> RenderGate{Render gate};
    RenderGate --> CheckPolicyMode{reverse_render_uses_field_policy?};
    CheckPolicyMode -- Yes --> CheckFieldPolicy{has_reverse_change_permission?};
    CheckPolicyMode -- No --> CheckBasePerms{_has_base_permission?};
    CheckFieldPolicy -- Deny --> SetVisibility;
    CheckBasePerms -- Deny --> SetVisibility{Mode?};
    SetVisibility -- hide --> Hide[Hide field];
    SetVisibility -- disable --> Disable[Disable field];
    CheckFieldPolicy -- Allow --> Visible[Visible/editable];
    CheckBasePerms -- Allow --> Visible;
  end

  subgraph Validate and Persist
    RenderNormal --> Clean;
    Hide --> Clean;
    Disable --> Clean;
    Visible --> Clean[clean: run cfg.clean if present];
    Clean --> Validate{has_reverse_change_permission?};
    Validate -- Deny --> AddError[Add field error];
    Validate -- Allow --> OK;
    AddError --> Persist;
    OK --> Persist[Persistence gate];
    Persist --> FilterPayload[Build payload only for allowed fields];
    FilterPayload --> Save[save: transaction.atomic; unbind before bind];
  end
    

Minimal examples

def only_staff(request, obj, config, selection):
    return getattr(request.user, "is_staff", False)

ReverseRelationConfig(..., permission=only_staff)
class OrgPolicy:
    def __init__(self, org_id: int):
        self.org_id = org_id
    permission_denied_message = "You lack access to this organization."
    def __call__(self, request, obj, config, selection):
        return getattr(request.user, "org_id", None) == self.org_id

ReverseRelationConfig(..., permission=OrgPolicy(org_id=42))
class CanBindAdapter:
    permission_denied_message = "Not allowed to bind this item."
    def has_perm(self, request, obj, config, selection):
        # delegate to some legacy checker
        return legacy_can_bind(request.user, selection)

ReverseRelationConfig(..., permission=CanBindAdapter())

See also