Skip to content

Permission Cookbook

These recipes provide drop-in patterns for the permission system. They highlight how AdditiveManagerPermission and OverrideManagerPermission compose with reusable checks, attribute overrides, and queryset filters.

Attribute-level rule sets

from general_manager.permission.manager_based_permission import (
    AdditiveManagerPermission,
    OverrideManagerPermission,
)


class InvoicePermission(AdditiveManagerPermission):
    __read__ = ["isAuthenticated"]
    __create__ = ["inGroup:finance"]
    __update__ = ["inGroup:finance"]
    __delete__ = ["isAdmin"]

    total_due = {"update": ["matches:status:open"]}
    paid_at = {"read": ["inGroup:finance"], "update": ["inGroup:finance"]}
  • Restrict write access to finance operators.
  • Allow anyone to read invoices but hide paid_at for unauthorised users.
  • matches uses the helper registered in the permission_checks registry to guard updates based on the current field value.

Delegating through __based_on__

class InvoiceAttachmentPermission(OverrideManagerPermission):
    __based_on__ = "invoice"
    __read__ = ["isAuthenticated"]
    __create__ = ["inGroup:finance"]
    file = {"update": ["inGroup:finance"], "delete": ["inGroup:finance"]}

Attachments inherit the invoice's permission outcome. If the linked invoice denies access, the attachment is denied as well. Filters from the invoice permission are automatically prefixed with invoice__ when applied to queries.

Combining custom checks with filters

from general_manager.permission.permission_checks import register_permission


@register_permission(
    "belongsToOrganisation",
    permission_filter=lambda user, config: {
        "filter": {f"{config[0]}__organisation_id": user.organisation_id}
    }
    if config
    else None,
)
def permission_belongs_to_org(instance, user, config):
    relation = getattr(instance, config[0])
    return relation.organisation_id == user.organisation_id

Use the permission by adding "belongsToOrganisation:customer" to __read__. The filter keeps queryset results inside the user's organisation without duplicating logic.

Filters can also return {"exclude": {...}} or include both filter and exclude keys:

@register_permission(
    "visibleInvoice",
    permission_filter=lambda user, _config: {
        "filter": {"organisation_id": user.organisation_id},
        "exclude": {"status": "archived"},
    },
)
def permission_visible_invoice(instance, user, _config):
    return (
        instance.organisation_id == user.organisation_id
        and instance.status != "archived"
    )

Guarding GraphQL mutations

Mutation classes can reuse permission checks for fine-grained control. The example below assumes an Invoice manager backed by a Django model:

from django.db.models import AutoField, CharField, TextField
from general_manager.interface import DatabaseInterface
from general_manager.manager import GeneralManager


class Invoice(GeneralManager):
    id: int
    status: str
    rejection_reason: str | None

    class Interface(DatabaseInterface):
        id = AutoField(primary_key=True)
        status = CharField(max_length=32)
        rejection_reason = TextField(null=True, blank=True)

    class Permission(InvoicePermission):
        ...

Because the GraphQL decorator emits ID inputs for conventional manager arguments, clients pass the invoice identifier. The decorator casts that input through the manager interface before calling the mutation permission or resolver, so both receive an Invoice instance:

from typing import Any

from general_manager.api.mutation import graph_ql_mutation
from general_manager.permission.base_permission import PermissionCheckError
from general_manager.permission.mutation_permission import MutationPermission


class RejectInvoicePermission(MutationPermission):
    @classmethod
    def check(cls, data: dict[str, Any], request_user: Any) -> None:
        invoice = data["invoice"]
        if invoice.status != "submitted":
            raise PermissionCheckError(
                request_user, ["Only submitted invoices can be rejected."]
            )
        if not request_user.groups.filter(name="finance_lead").exists():
            raise PermissionCheckError(
                request_user, ["Only finance leads may reject invoices."]
            )


@graph_ql_mutation(permission=RejectInvoicePermission)
def reject_invoice(info, invoice: Invoice, reason: str) -> Invoice:
    invoice.update(
        creator_id=getattr(info.context.user, "id", None),
        status="rejected",
        rejection_reason=reason,
    )
    return invoice
  • graph_ql_mutation inspects the resolver signature and return annotation to build the GraphQL payload; no separate base_type configuration is required.
  • MutationPermission.check is a classmethod that receives normalized mutation data and request_user, so manager-typed arguments are available as manager instances before enforcing domain rules.
  • Raise PermissionCheckError(request_user, [...]) from custom checks when a domain-specific denial should be converted into a GraphQL permission error.
  • Call GeneralManager.update instead of writing to model fields directly; it re-runs permission checks and records history comments when provided.

Testing shortcuts

from general_manager.permission.base_permission import BasePermission, PermissionCheckError


def test_finance_cannot_delete_archived_invoice(finance_user, archived_invoice):
    with pytest.raises(PermissionCheckError):
        BasePermission.check_delete_permission(
            archived_invoice,
            request_user=finance_user,
        )

Combine permission helper calls with fixtures to cover both granted and denied scenarios. Stubbing the audit logger makes it easy to assert on emitted PermissionAuditEvent instances.