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)
| File | Size | Uploaded | |
|---|---|---|---|
| django_static_acl-0.1.0.tar.gz | 29.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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
|