Metadata-Version: 2.4
Name: django-static-acl
Version: 0.1.0
Summary: Static ACL relation mapping and composable Django access rules
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: Django
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: Django>=4.2
Requires-Dist: djangorestframework>=3.15.2
Provides-Extra: drf
Provides-Extra: test
Requires-Dist: coverage>=7; extra == "test"

# 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

```shell
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.

```python
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

```python
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.

```shell
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(...)`.

```python
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)`:

```python
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

```python
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:

```python
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:

```python
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.

```shell
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

```shell
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](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.
