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"
    )

Static user-only decisions

When a rule depends only on the requesting user and its configuration, return a PermissionFilterDecision instead of making the list/search path inspect every row. This example lets staff users read every Project and denies the entire manager for other users:

from general_manager.manager import GeneralManager
from general_manager.permission import PermissionFilterDecision, register_permission
from general_manager.permission.manager_based_permission import AdditiveManagerPermission


def is_project_reviewer_filter(user, _config):
    return (
        PermissionFilterDecision.ALLOW_ALL
        if getattr(user, "is_staff", False)
        else PermissionFilterDecision.DENY_ALL
    )


@register_permission(
    "isProjectReviewer", permission_filter=is_project_reviewer_filter
)
def is_project_reviewer(_instance, user, _config):
    return bool(getattr(user, "is_staff", False))


class Project(GeneralManager):
    class Permission(AdditiveManagerPermission):
        __read__ = ["isProjectReviewer"]

Static decisions must be correct for every row. Use None when the rule needs the concrete instance, and use a mapping when a queryset constraint can narrow the candidate rows but does not itself prove authorization. Read expressions joined with & are ANDed; entries in __read__ remain OR alternatives.

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.

Preserve an authorized bucket subset

If a custom integration has already evaluated a per-instance rule, reconstruct the result from the original bucket instead of issuing a second identifier lookup. This works for database, request-backed, and calculation buckets:

from typing import Any

from general_manager.bucket import Bucket
from myapp.managers import Invoice


def visible_invoices(
    invoices: Bucket[Invoice],
    request_user: Any,
) -> Bucket[Invoice]:
    is_finance = request_user.groups.filter(name="finance").exists()
    allowed = [
        invoice
        for invoice in invoices
        if invoice.status != "archived" or is_finance
    ]
    return invoices.with_instances(allowed)

with_instances() keeps exactly the selected managers and returns the same concrete bucket family. Supply selected managers in source order when the existing order matters. The generated GraphQL authorization flow uses the same contract after row-level checks. See the bucket concept and GraphQL how-to for the backend behavior and a generic resolver helper.

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.