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_atfor unauthorised users. matchesuses the helper registered in thepermission_checksregistry 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_mutationinspects the resolver signature and return annotation to build the GraphQL payload; no separatebase_typeconfiguration is required.MutationPermission.checkis a classmethod that receives normalized mutation data andrequest_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.updateinstead 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.