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