Skip to main content

django-static-acl

Static ACL relation paths, composable access rules, and Django REST Framework integration. Requires Python 3.10+, Django 4.2+, and DRF 3.15.2+. The DRF minimum ensures serializer validation supports model UniqueConstraint declarations.

Installation

pip install -e .

The legacy [drf] extra is retained as an empty compatibility extra. Add django_acl to INSTALLED_APPS before other apps overriding makemigrations if using its hook.

ACL = {
    "DEFAULT_PARENT_MODEL": "organizations.Organization",
    "AUTO_RELATION_ON": True,
    "REMAP_BEFORE_MAKEMIGRATIONS": False,
    "GENERATED_MODULE": "config.acl_paths",
    "CASCADE": False,
}

PARENT_MODEL is accepted as a legacy alias for DEFAULT_PARENT_MODEL. Create the package containing the generated module first. Choose a dedicated file: generation replaces its contents.

Models and mapping

from django.conf import settings
from django.db import models
from django_acl.models import ACLModel

class Organization(ACLModel):
    owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    members = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name="organizations")

class Document(ACLModel):
    organization = models.ForeignKey(Organization, on_delete=models.CASCADE)
    created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)

ACLModel is abstract and adds no columns. Its two mapping attributes are:

Attribute Default Meaning
acl_parent_path None Manual tuple of forward field names; () means self; None uses generated mapping
AUTO_RELATION_ON None Inherit the global setting; False requires a manual path

Discovery follows forward FK/OneToOne relations, including intermediate non-ACL models. Reverse and M2M relations are excluded from discovery; forward M2M paths can be supplied explicitly. Missing paths and equal shortest paths are errors. Cycles terminate. Graph distance does not establish ownership: use manual paths or EXCLUDED_RELATIONS when needed.

python manage.py remap_acl
python manage.py remap_acl --check

Commit the generated module. Generation uses no database queries and replaces the file atomically. --check fails on drift without writing. Enabling REMAP_BEFORE_MAKEMIGRATIONS runs generation before makemigrations; its --check and --dry-run modes only check the mapping.

Runtime validates manual/generated paths and never performs BFS. Generated entries include the root label so a changed root requires regeneration. Restart workers after regeneration because Python caches imported modules.

Rules and configuration

Built-in rules are allow_all, deny_all, creator, parent_member, parent_owner, and staff. String aliases are all, deny, creator, parent_member, parent_owner, and staff; member and owner are also accepted. Unknown aliases raise ImproperlyConfigured. There is no implicit superuser bypass. Combine rules with any_of(...) and all_of(...).

ACL = {
    "DEFAULT_PARENT_MODEL": "organizations.Organization",
    "CREATOR_FIELD": "created_by",
    "OWNER_FIELD": "owner",
    "MEMBER_FIELD": "members",
    "PARENT_QUANTIFIER": "all",  # or "any"
    "EXCLUDED_RELATIONS": (),
    "CASCADE": False,
    "RULES": {"read": "parent_member", "delete": "parent_owner"},
    "MODEL_OPTIONS": {
        "organizations.document": {
            "EXCLUDED_RELATIONS": ("created_by",),
            "RULES": {
                "read": "parent_member",
                "create": "parent_member",
                "update": "creator",
                "delete": "parent_owner",
            },
        },
    },
}

Model options override global options. A model's RULES dictionary replaces the global dictionary; missing actions deny. Field names can also be overridden per model. Membership expects a relation to users; roles, expiry, or membership records require a custom rule.

No reachable root denies access. Null branches contribute no root. With multiple roots, all requires a nonempty set and permission on every root; any requires at least one permitted root. SQL scopes avoid duplicate rows from ACL joins. Ownership and membership decisions are not cached between requests.

A rule supports both rule(context) and rule.scope(queryset, context):

from django_acl.rules import Context, parent_owner, acl_rule

context = Context(user=user, action="update", model=Document, obj=document)
if not parent_owner(context):
    raise PermissionError("Access denied")

@acl_rule(scope=lambda qs, ctx: qs.filter(created_by=ctx.user))
def own_document(ctx):
    return ctx.obj.created_by_id == ctx.user.pk

Context also accepts data (validated write data), request, and view. Custom predicates without a SQL scope cannot be used for queryset filtering. Their object and SQL implementations must express the same policy. Built-in creator/owner rules respect the target field of a ForeignKey, including to_field values other than the user primary key.

DRF

from rest_framework.permissions import IsAuthenticated
from rest_framework.viewsets import ModelViewSet
from django_acl.drf import ACLPermission, ACLViewSetMixin
from django_acl.rules import creator, parent_member, parent_owner, any_of

class DocumentViewSet(ACLViewSetMixin, ModelViewSet):
    permission_classes = [IsAuthenticated, ACLPermission]
    # Set queryset and serializer_class as usual.
    read_acl_rule = parent_member
    create_acl_rule = parent_member
    update_acl_rule = any_of(creator, parent_owner)
    delete_acl_rule = parent_owner
    parent_write_acl_rule = parent_member
    acl_server_fields = {"created_by": lambda request: request.user}
    acl_cascade = None  # inherit ACL["CASCADE"]

Rule precedence: view attributes, permission subclass attributes, model/global RULES settings, then deny. Functions assigned as class attributes are called without binding them to the view or permission instance.

Action Rule
list, retrieve read
create create
update, partial_update update
destroy delete
custom action, e.g. publish publish
APIView GET/HEAD/OPTIONS read
APIView POST, PUT/PATCH, DELETE create, update, delete

ViewSet OPTIONS uses metadata: configure metadata_acl_rule explicitly. Authentication is separate; use IsAuthenticated as needed. all deliberately permits anonymous access, but ownership, creator, membership and staff rules do not.

Lists are scoped in SQL before pagination. Detail lookups use the current action's scope; updating does not require read permission. Create/update check validated future state before saving. When the root itself is written, parent_member evaluates its validated replacement membership list, including creation before the M2M relation exists in the database. Creating under a parent or supplying a new first path relation additionally checks parent_write_acl_rule, whose default is parent_owner. This also protects creator-only updates against moving an object into a foreign parent.

acl_server_fields supplies trusted native create values before DRF field and serializer-level validation, including UniqueTogetherValidator, and rejects validated updates to those fields, including their FK _id aliases. When both a relation and its _id are provided, conflicting values return HTTP 400; matching values are normalized to one representation before checking and saving. Trusted create values replace both untrusted representations. Input values are ignored on create, required server fields may be omitted, and hidden server fields are supplied internally for composite validation. Their validators see the trusted native value; serializer-level validation sees the same proposed state as saving. CreatorOnlyPermission also ignores author _id input before serializer validation. Checked data is passed to serializer.save(). Primary keys cannot change on update, including serializer aliases and relation-based primary keys. Repeating the existing key is allowed. Custom serializers must preserve checked values and must not implement nested parent writes without explicit ACL integration.

Personal model ViewSets

Use CreatorOnlyPermission with ACLViewSetMixin for authenticated CRUD on objects belonging to their author:

from rest_framework.viewsets import ModelViewSet
from django_acl.drf import ACLViewSetMixin, CreatorOnlyPermission

class PersonalDocumentViewSet(ACLViewSetMixin, ModelViewSet):
    queryset = PersonalDocument.objects.all()
    serializer_class = PersonalDocumentSerializer
    permission_classes = [CreatorOnlyPermission]

The permission scopes lists and detail lookups to the current author, assigns created_by=request.user on create, ignores supplied author values on create, and rejects author input on update. The serializer does not require the author on POST or PUT. A unique author field (including OneToOneField) is checked against the write database before creation; a duplicate returns HTTP 400. Single-author UniqueConstraint declarations are validated too, including conditional constraints on create and update using the proposed object state. FK changes replace the old relation key during validation; conditions may use the relation name or its _id attribute, including nullable relations. This constraint check includes trusted acl_server_fields values. Callable server values are resolved once per model per request and reused when saving. Its trusted default participates in DRF uniqueness validators, including author/field pairs, before saving. If the public serializer omits the author, an internal hidden field supplies the validation default without adding the author to responses. On update, validators use the existing author, including when an explicit staff rule permits editing another author's object. Anonymous requests are denied without a separate IsAuthenticated permission. Custom actions deny until explicitly configured.

The creator field defaults to created_by, configurable via CREATOR_FIELD (globally or in MODEL_OPTIONS). Ordinary Django models are supported: no ACLModel, root setting, or generated path is needed when cascading checks are disabled. Cascade settings are inherited normally.

This permission checks authorship only; it imposes no parent placement policy. Set parent_write_acl_rule explicitly if placement also requires permission on a parent. Explicit ViewSet rules override the permission's CRUD defaults.

Cascading child checks

ACL["CASCADE"] sets the default (False). Optional model option CASCADE overrides it. On a ViewSet, acl_cascade=True enables cascading checks and acl_cascade=False disables them; None inherits settings.

With cascading checks disabled, only the requested object's permissions are checked. Child representations and ordinary Django deletion cascades are left to the application.

With cascading checks enabled:

Operation Behavior
Read Return the permitted parent and only readable children, recursively. A forbidden singular child is represented as null.
Update Permit changes to the parent's own fields. Children, their fields, links, and composition must remain unchanged. A detected change rolls back the whole database transaction and returns 403.
Delete Require delete permission for all descendants. Any refusal denies the entire deletion with 403.

Children use RULES from settings for their own model and action. Their ViewSets are not invoked; ViewSet-specific rules apply only when that ViewSet is called. Missing child rules deny child access. No children means no additional denial.

By default, children are derived by reversing configured ACL paths: the nearest ACL ancestor is the parent, potentially across intermediate non-ACL models. For a different hierarchy, set CHILD_RELATIONS on a model:

ACL["MODEL_OPTIONS"]["organizations.organization"] = {
    "CHILD_RELATIONS": {
        "organizations.document": ("organization",),
    },
}

Keys are child model labels; paths go from child to parent. None means automatic discovery; {} stops hierarchy traversal at that model. Cyclic child hierarchies are configuration errors. Delete additionally checks ACL-enrolled objects in Django's actual deletion cascade, including alternate FK relations. Enrollment includes ACLModel subclasses and ordinary models explicitly registered in CHILD_RELATIONS, even under another parent model. Restricting CHILD_RELATIONS does not bypass these deletion checks.

Standard nested serializers, related-ID/slug/URL fields, and prefetched model lists (including Prefetch(to_attr=...)) are filtered automatically. Descendants are checked even when represented directly on an ancestor's serializer. Dotted sources such as profile.pk and profile.created_by.username check intermediate child objects before exposing the value; a forbidden child yields null. Dotted paths through child collections (such as documents.count) require explicit ACL serialization and raise a configuration error. Dynamic related collections must contain instances of one model; mixed/scalar collections raise a configuration error rather than bypassing ACL. Plain nested Serializer fields without Meta.model also support prefetched model instances, including nested descendants. Values whose model cannot be inferred fail closed. ListField with a nested serializer or related field is supported as well. Dict containers or lists of containers containing model fields require explicit ACL serialization and raise a configuration error instead of silently bypassing checks; scalar containers retain their normal behavior. Opaque SerializerMethodField, custom representation methods, and externally computed aggregates must explicitly use django_acl.serialization.readable(queryset, context); arbitrary response data cannot be inferred as a child relation. Filtering never adds response fields to serializers. A plain root Serializer without Meta.model is supported: the model is inferred from its instance, queryset, or the queryset scoped by ACL (including empty paginated results). Set acl_model = MyModel on the ViewSet when the model cannot be inferred. An unknown model with cascading checks enabled raises a configuration error instead of bypassing child filtering.

The update guard snapshots configured descendants and their M2M links before and after saving, inside one transaction. M2M snapshots include forward, reverse, and hidden relations and respect custom through-table foreign keys, including references to unique fields via to_field. Its memory/query cost grows with the child hierarchy. Transactions roll back database mutations, not external side effects; use transaction.on_commit for side effects. The guard does not lock all membership/descendant rows against concurrent changes.

Manual integration and tests

For APIView/manual integration, set acl_manual_checks=True only after supplying all hooks: permission.scope(request, view, queryset) for querysets, check_object_permissions for existing objects, and permission.check_write(request, view, model, obj_or_none, validated_data) before writes. Cascade serialization uses serialization.protect; immutable-child updates require transactional before/after children.snapshot checks, as in ACLViewSetMixin. ACLPermission alone on an unsupported view fails closed.

Update and delete use router.db_for_write(model, instance=instance) to select the transaction database. If the object came from another database, it is fetched from the write database before serializer validation and checked again there. The refetch retains the ViewSet queryset restrictions and DRF filter backends; ACL scopes are built for the write database. Built-in DRF uniqueness validators on fields and serializers use the write database for create, PUT, and PATCH; updates pass the instance hint to the router and exclude the existing object. An object outside that filtered queryset returns 404, including when its author changed in the primary database. A missing row returns 404; stale replica ownership cannot authorize a write. Built-in parent_owner and parent_member resolve proposed FK/M2M parent paths and membership from the write database for create/update/delete checks. Cached serializer relation objects do not supply trusted ownership or membership; a missing proposed parent denies placement. Descendant snapshots use the write database. Routers must select a stable write database for the operation; custom serializers and signals must keep their writes in that transaction to receive rollback protection.

Custom/bulk actions, services, and overrides that bypass the mixin hooks must perform equivalent checks explicitly. ACLModel does not intercept ORM writes.

python -m django test django_acl.tests --settings=django_acl.tests.settings

The standalone suite uses SQLite and an isolated test app. It is skipped under unrelated project settings: run the explicit command in CI. This package has not been published; choose a license and establish a compatibility matrix before publication.

Coverage and review regressions

pip install -e '.[test]'
coverage run -m django test django_acl.tests --settings=django_acl.tests.settings
coverage report

Coverage includes the package runtime and management commands, excludes only the standalone test app, and requires 100% statement/branch coverage. Test success and coverage are separate checks. All 175 tests pass, including regression tests for the resolved issues from all sixteen review rounds documented in REVIEW.md. None of these regressions are skipped or marked as expected failures.

Coverage is measured on Python 3.12, Django 5.0.7, DRF 3.15.2, SQLite. All 175 tests also pass with Django 4.2 and DRF 3.15.2. The suite includes two database aliases with colliding primary keys. These runs do not establish compatibility with all declared dependency versions or database engines.

Metadata

Release files for django-static-acl 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for django-static-acl 0.1.0
File Size Uploaded
django_static_acl-0.1.0.tar.gz 29.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for django-static-acl 0.1.0
File Interpreter ABI Platform
django_static_acl-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 55.4 kB

Release files / django_static_acl-0.1.0.tar.gz

Download URL django_static_acl-0.1.0.tar.gz
Size 29.1 kB
Tags Source
SHA-256 checksum
How to use checksums
01136b247352b28b7833f3f568e1100ce68d744d201af16b6687386bb6a4b007
BLAKE2b-256 checksum
How to use checksums
36277d8abbc1fbeffe3c3c51c3b98453a28674b8417ed5ab545346c507f638a3
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.3

Release files / django_static_acl-0.1.0-py3-none-any.whl

Download URL django_static_acl-0.1.0-py3-none-any.whl
Size 26.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d0b4faea34c387ac8c5f921162f02e16067ff9eb7111574c4e751e6abc5953f4
BLAKE2b-256 checksum
How to use checksums
bdd516ca18c3a76b5959bd7f21b5222d64eb4e6f177980f3604b62f10bff0593
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.3

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page