# django-topdownrbac

A Django library that implements hierarchical, top-down role-based access control (RBAC) with automatic permission propagation through object trees.

## Installation

```bash
uv add django-topdownrbac
# or
pip install django-topdownrbac
```

## Django configuration

Add the app to your settings, set the custom user model, and register the authentication backend:

```python
# settings.py

INSTALLED_APPS = [
    "topdownrbac",
    # ... your apps ...
    "treebeard",
]

AUTH_USER_MODEL = "topdownrbac.UserSubject"

AUTHENTICATION_BACKENDS = [
    "django.contrib.auth.backends.ModelBackend",
    "topdownrbac.backends.restricted_object_backend.RestrictedObjectBackend",
]
```

Then run migrations:

```bash
python manage.py migrate
```

## Model overview

```puml
@startuml
class treebeard.MP_Node {}

class Lib.RestrictedObject {
    +AutoField restricted_object_id
    +FK parent
    +change_parent()
    +remove_parent()
    +who_has_perm()
    +get_queryset_for_user()
}

class Lib.Subject {
    +AutoField subject_id
}

class django.AbstractUser {}

class Lib.UserSubject {
    +CharField name
}

class Lib.UserGroupSubject {
    +M2M user_subjects
    +add_user()
    +remove_user()
}

class Lib.Role {
    +CharField name
    +M2M permissions
    +BooleanField editable
    +BooleanField externally_provisioned
    +add_permission()
    +remove_permission()
}

class Lib.RoleBinding {
    +CharField name
    +BooleanField propagate
}

class django.Permission {
    +String codename
}

class Lib.UserPermission {
    +FK user
    +FK perm
    +FK restricted_object
    +FK source
}

class YourApp.Organization {}
class YourApp.Project {}
class YourApp.UserProfile {}

MP_Node <|-- RestrictedObject
RestrictedObject "0..1" -- "*" RestrictedObject : parent

Subject <|-- UserSubject
Subject <|-- UserGroupSubject
AbstractUser <|-- UserSubject
UserGroupSubject "*" - "*" UserSubject

django.Permission "*" -- "*" Role
RoleBinding "*" -- "1" Role
RoleBinding "*" -- "1" Subject
RoleBinding "*" -- "1" RestrictedObject

UserPermission "*" -- "1" UserSubject
UserPermission "*" -- "1" django.Permission
UserPermission "*" -- "1" RestrictedObject
UserPermission "*" -- "1" RoleBinding : source

RestrictedObject <|-- Organization
RestrictedObject <|-- Project
UserSubject "1" -- "1" UserProfile
@enduml
```

## Usage

### Creating users

`UserSubject` extends Django's `AbstractUser`, so it works like the standard user model:

```python
from topdownrbac.models import UserSubject

alice = UserSubject.objects.create_user(
    username="alice", password="secret", name="Alice"
)
bob = UserSubject.objects.create_user(
    username="bob", password="secret", name="Bob"
)
```

```puml
@startuml
skinparam objectBorderColor #333333
skinparam objectFontColor #000000
skinparam objectBackgroundColor<<user>> #4a90d9

object "alice : UserSubject" as alice <<user>>
object "bob : UserSubject" as bob <<user>>
@enduml
```

### Setting up a hierarchy

`RestrictedObject` is organized as a tree. Set `parent` to place an object under another:

```python
from topdownrbac.models import RestrictedObject

# Root node
org = RestrictedObject(parent=None)
org.save()

# Children
team = RestrictedObject(parent=org)
team.save()

project = RestrictedObject(parent=team)
project.save()
```

```puml
@startuml
skinparam objectBorderColor #333333
skinparam objectFontColor #000000
skinparam objectBackgroundColor<<user>> #4a90d9
skinparam objectBackgroundColor<<ro>> #66bb6a

object "alice : UserSubject" as alice <<user>>
object "bob : UserSubject" as bob <<user>>

object "org : RestrictedObject" as org <<ro>>
object "team : RestrictedObject" as team <<ro>>
object "project : RestrictedObject" as project <<ro>>

org --> team : parent
team --> project : parent
@enduml
```

To move an object to a different parent:

```python
project.change_parent(other_team)
```

To detach an object from its parent, making it a root node:

```python
# These two are equivalent
project.remove_parent()
project.change_parent(None)
```

When an object is moved or detached, all propagated permissions are automatically recalculated: permissions inherited from the old parent are removed, and permissions from the new parent (if any) are applied.

### Creating your own restricted objects

Your domain models can inherit directly from `RestrictedObject`:

```python
from topdownrbac.models import RestrictedObject

class Organization(RestrictedObject):
    name = models.CharField(max_length=200)

class Project(RestrictedObject):
    name = models.CharField(max_length=200)
    description = models.TextField(blank=True)
```

This gives you the full hierarchy and permission system on your own models.

### Roles and permissions

A `Role` groups one or more Django `Permission` objects. When you add or remove a permission from a role, the change automatically cascades to all users bound to that role.

```python
from django.contrib.auth.models import Permission
from topdownrbac.models import Role

# Create a role
viewer = Role.objects.create(name="Viewer")

# Add permissions to it
view_perm = Permission.objects.get(codename="view_organization")
viewer.add_permission(view_perm)

# Add multiple permissions at once
perms = Permission.objects.filter(codename__in=["view_project", "change_project"])
editor = Role.objects.create(name="Editor")
editor.add_many_permissions(list(perms))

# Remove a permission (cascades to all bound users)
editor.remove_permission(view_perm)
```

```puml
@startuml
skinparam objectBorderColor #333333
skinparam objectFontColor #000000
skinparam objectBackgroundColor<<user>> #4a90d9
skinparam objectBackgroundColor<<ro>> #66bb6a
skinparam objectBackgroundColor<<role>> #fdd835
skinparam objectBackgroundColor<<perm>> #ffa726

object "alice : UserSubject" as alice <<user>>
object "bob : UserSubject" as bob <<user>>

object "org : RestrictedObject" as org <<ro>>
object "team : RestrictedObject" as team <<ro>>
object "project : RestrictedObject" as project <<ro>>
org --> team : parent
team --> project : parent

object "viewer : Role" as viewer <<role>>
object "editor : Role" as editor <<role>>

object "view_organization : Permission" as view_org <<perm>>
object "view_project : Permission" as view_proj <<perm>>
object "change_project : Permission" as change_proj <<perm>>

viewer -- view_org
editor -- view_proj
editor -- change_proj
@enduml
```

Roles can be locked to prevent accidental changes:

```python
viewer.set_immutable()       # raises PermissionDenied on add/remove/delete
viewer.set_editable()        # unlocks the role
```

### Assigning roles with RoleBindings

A `RoleBinding` links a subject (user or group) to a role on a specific restricted object. The `propagate` flag controls whether the permissions apply only to that object or to all its descendants as well.

#### Without propagation

When `propagate=False`, the user only receives permissions on the exact object specified:

```python
from topdownrbac.models import RoleBinding

# Give alice the Viewer role on org only
RoleBinding.objects.create(
    role=viewer,
    subject=alice,
    restricted_object=org,
    propagate=False,
)
```

```puml
@startuml
skinparam objectBorderColor #333333
skinparam objectFontColor #000000
skinparam objectBackgroundColor<<user>> #4a90d9
skinparam objectBackgroundColor<<ro>> #66bb6a
skinparam objectBackgroundColor<<role>> #fdd835
skinparam objectBackgroundColor<<perm>> #ffa726
skinparam objectBackgroundColor<<rb>> #ab47bc
skinparam objectBackgroundColor<<up>> #ce93d8

object "alice : UserSubject" as alice <<user>>

object "org : RestrictedObject" as org <<ro>>
object "team : RestrictedObject" as team <<ro>>
object "project : RestrictedObject" as project <<ro>>
org --> team : parent
team --> project : parent

object "viewer : Role" as viewer <<role>>

object "view_organization : Permission" as view_org <<perm>>
viewer -- view_org

object "rb1 : RoleBinding\npropagate = false" as rb1 <<rb>>

rb1 --> alice : subject
rb1 --> viewer : role
rb1 --> org : restricted_object

object "alice / view_organization / org" as up1 <<up>>

rb1 ..> up1 : generates
@enduml
```

Alice gets a single `UserPermission` row: she can `view_organization` on `org`, but **not** on `team` or `project`.

#### With propagation

When `propagate=True`, the user receives permissions on the object **and all its descendants**:

```python
# Give bob the Editor role on org and all its descendants
RoleBinding.objects.create(
    role=editor,
    subject=bob,
    restricted_object=org,
    propagate=True,  # bob gets permissions on org, team, and project
)
```

```puml
@startuml
skinparam objectBorderColor #333333
skinparam objectFontColor #000000
skinparam objectBackgroundColor<<user>> #4a90d9
skinparam objectBackgroundColor<<ro>> #66bb6a
skinparam objectBackgroundColor<<role>> #fdd835
skinparam objectBackgroundColor<<perm>> #ffa726
skinparam objectBackgroundColor<<rb>> #ab47bc
skinparam objectBackgroundColor<<up>> #ce93d8

object "bob : UserSubject" as bob <<user>>

object "org : RestrictedObject" as org <<ro>>
object "team : RestrictedObject" as team <<ro>>
object "project : RestrictedObject" as project <<ro>>
org --> team : parent
team --> project : parent

object "editor : Role" as editor <<role>>

object "view_project : Permission" as view_proj <<perm>>
object "change_project : Permission" as change_proj <<perm>>
editor -- view_proj
editor -- change_proj

object "rb2 : RoleBinding\npropagate = true" as rb2 <<rb>>

rb2 --> bob : subject
rb2 --> editor : role
rb2 --> org : restricted_object

object "bob / view_project / org" as up2 <<up>>
object "bob / change_project / org" as up3 <<up>>
object "bob / view_project / team" as up4 <<up>>
object "bob / change_project / team" as up5 <<up>>
object "bob / view_project / project" as up6 <<up>>
object "bob / change_project / project" as up7 <<up>>

rb2 ..> up2 : generates
rb2 ..> up3
rb2 ..> up4
rb2 ..> up5
rb2 ..> up6
rb2 ..> up7
@enduml
```

Bob gets 6 `UserPermission` rows: `view_project` and `change_project` on each of `org`, `team`, and `project`. When new children are added under `org`, Bob automatically receives permissions on them too.

`UserPermission` rows are created automatically when a `RoleBinding` is saved, and removed automatically when it is deleted. The `role`, `subject`, `restricted_object`, and `propagate` fields are immutable after creation — attempting to change them will raise `PermissionDenied`. To modify a binding, delete it and create a new one; the library will handle cleaning up the old `UserPermission` rows and generating the new ones.

### Checking permissions

The library ships with a custom authentication backend, so Django's standard `has_perm` works out of the box. Pass the object as the second argument:

```python
alice.has_perm("view_organization", org)       # True
alice.has_perm("view_organization", project)   # False (propagate=False)

bob.has_perm("change_project", project)        # True (propagated from org)
```

### Filtering querysets

Use `get_queryset_for_user()` to get all objects of a given type that a user has a specific permission on:

```python
# All organizations alice can view
qs = Organization.get_queryset_for_user(alice, "view_organization")
```

In a DRF viewset:

```python
class OrganizationViewSet(viewsets.ModelViewSet):
    def get_queryset(self):
        return Organization.get_queryset_for_user(
            self.request.user, "view_organization"
        )
```

Superusers automatically get all objects. Unauthenticated users raise `PermissionDenied`.

### Listing users with a permission on an object

Use `who_has_perm()` to get all users that have a specific permission on a given object. This is the inverse of `get_queryset_for_user()` -- instead of "which objects can this user access?", it answers "which users can access this object?".

```python
# All users who can view this organization
users = org.who_has_perm("view_organization")
```

The method returns a `UserSubject` queryset, so it can be filtered further:

```python
active_viewers = org.who_has_perm("view_organization").filter(is_active=True)
```

Users who have the permission via any path (direct binding, group membership, or propagation from an ancestor) are included. Each user appears only once regardless of how many bindings grant them the permission.

### Groups

`UserGroupSubject` lets you assign roles to a group of users. Permissions are granted to all members, and membership changes automatically cascade.

```python
from topdownrbac.models import UserGroupSubject

team_group = UserGroupSubject.objects.create()
team_group.add_user(alice)
team_group.add_user(bob)

# Bind a role to the group
RoleBinding.objects.create(
    role=editor,
    subject=team_group,
    restricted_object=team,
    propagate=True,
)

# Both alice and bob now have Editor permissions on team and its descendants.

# Adding a user to the group grants them the existing permissions
team_group.add_user(charlie)

# Removing a user revokes them
team_group.remove_user(alice)
```

```puml
@startuml
skinparam objectBorderColor #333333
skinparam objectFontColor #000000
skinparam objectBackgroundColor<<user>> #4a90d9
skinparam objectBackgroundColor<<group>> #29b6f6
skinparam objectBackgroundColor<<ro>> #66bb6a
skinparam objectBackgroundColor<<role>> #fdd835
skinparam objectBackgroundColor<<rb>> #ab47bc

object "alice : UserSubject" as alice <<user>>
object "bob : UserSubject" as bob <<user>>
object "charlie : UserSubject" as charlie <<user>>

object "team_group : UserGroupSubject" as team_group <<group>>
team_group --> bob : member
team_group --> charlie : member

object "org : RestrictedObject" as org <<ro>>
object "team : RestrictedObject" as team <<ro>>
object "project : RestrictedObject" as project <<ro>>
org --> team : parent
team --> project : parent

object "viewer : Role" as viewer <<role>>
object "editor : Role" as editor <<role>>

object "rb1 : RoleBinding\nalice / viewer / org\npropagate=false" as rb1 <<rb>>
object "rb2 : RoleBinding\nbob / editor / org\npropagate=true" as rb2 <<rb>>
object "rb3 : RoleBinding\nteam_group / editor / team\npropagate=true" as rb3 <<rb>>

rb1 --> alice : subject
rb1 --> viewer : role
rb1 --> org : restricted_object

rb2 --> bob : subject
rb2 --> editor : role
rb2 --> org : restricted_object

rb3 --> team_group : subject
rb3 --> editor : role
rb3 --> team : restricted_object
@enduml
```

## Management commands

### Provisioning roles from YAML

Roles can be defined in a YAML file and synced with the database using the `import_roles` command. Imported roles are automatically marked as `externally_provisioned=True` and `editable=False` — they become immutable and can only be modified by updating the YAML file and re-running the command.

```yaml
# roles.yaml
roles:
  viewer:
    permissions:
      - view_organization
      - view_project
  editor:
    permissions:
      - view_organization
      - change_organization
      - view_project
      - change_project
```

```bash
python manage.py import_roles roles.yaml
```

On each sync:

- **New roles** in the YAML are created with `externally_provisioned=True` and `editable=False`.
- **Modified roles** (permissions added or removed) are updated, and the changes cascade to all users bound to that role.
- **Removed roles** (present in the database but no longer in the YAML) are deleted, along with their RoleBindings and UserPermissions.

Manually created roles (`externally_provisioned=False`) are never affected by the sync.

### Checking permission consistency

The `check_perm_consistency` command audits the `UserPermission` table against existing `RoleBinding`s. It detects orphaned rows (stale references), missing rows, and can optionally fix them.

```bash
# Report inconsistencies
python manage.py check_perm_consistency

# Report and fix automatically
python manage.py check_perm_consistency -u
```

This can be run as a periodic task or manually if inconsistencies are suspected.
