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.
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 manager arguments, the resolver and the accompanying mutation permission receive the identifier and must instantiate the manager explicitly:
from typing import Any
from general_manager.api.mutation import graph_ql_mutation
from general_manager.permission.mutation_permission import MutationPermission
class RejectInvoicePermission(MutationPermission):
@classmethod
def check(cls, data: dict[str, Any], request_user: Any) -> None:
invoice_id = int(data["invoice"])
invoice = Invoice(id=invoice_id)
if invoice.status != "submitted":
cls.raise_error("Only submitted invoices can be rejected.")
if not request_user.groups.filter(name="finance_lead").exists():
cls.raise_error("Only finance leads may reject invoices.")
@graph_ql_mutation(permission=RejectInvoicePermission)
def reject_invoice(info, invoice: Invoice, reason: str) -> Invoice:
invoice_id = int(invoice)
manager = Invoice(id=invoice_id)
manager.update(
creator_id=getattr(info.context.user, "id", None),
status="rejected",
rejection_reason=reason,
)
return manager
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 the mutation data andrequest_user, so convert IDs into managers before enforcing domain rules.- Use
raise_error()to produce a structured GraphQL error withsuccess=False. - 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.