Backend
Roles, permissions and feature flags
pleate_backend.rbac builds on Django auth instead of replacing it. A role is a Django Group with a RoleInfo row (description, is_default); global permissions keep working through ModelBackend; ObjectPermissionBackend adds permissions on single objects, held by a user directly or through a role. Feature flags live in the same app because they answer the same question: may this user see this?
In Python
from pleate_backend.rbac.services import (
assign_role, remove_role, set_roles, has_role,
grant, revoke, users_with_perm, clear_object_permissions,
feature_enabled, enabled_features,
)
assign_role(user, "editor")
set_roles(user, ["editor", "viewer"])
has_role(user, "editor") # True
grant(user, "catalog.change_product", product) # this product only
grant(editors_group, "catalog.view_product", product)
user.has_perm("catalog.change_product", product) # object level, through the backend
users_with_perm("catalog.change_product", product)
clear_object_permissions(product) # before deleting it
feature_enabled(user, "new-editor")Roles flagged is_default are given to every new account (on user_signed_up), which is the usual way to grant a baseline such as catalog.view_product.
Permission classes
pleate_backend.rbac.permissions provides DRF permission factories. Each call returns a class, so they compose with | and & like DRF's own.
| Class | Passes when |
|---|---|
| HasPerm("app.codename", ...) | The user has every listed global permission. |
| HasRole("editor", ...) | The user has any listed role. Superusers pass. |
| HasObjectPerm("app.codename") | The user has the permission on the object, or globally. |
| HasFeature("new-ui") | The flag is on for the user. |
| ModelPerm("app.model") | view, add, change or delete on the model, chosen from the HTTP method. |
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from pleate_backend.rbac.permissions import HasFeature, HasObjectPerm, HasRole, ModelPerm
class ProductViewSet(viewsets.ModelViewSet):
# Read for everyone with view_product, write with add/change/delete_product
permission_classes = [IsAuthenticated, ModelPerm("catalog.product")]
class PriceViewSet(viewsets.ModelViewSet):
# Managers, or anyone granted change_price on that specific price list
permission_classes = [IsAuthenticated, HasRole("manager") | HasObjectPerm("catalog.change_price")]
class InsightsView(APIView):
permission_classes = [IsAuthenticated, HasFeature("insights")]On the frontend
/auth/me carries extra.roles, extra.permissions (sorted global app.codename strings) and extra.features, so the UI can hide what the API would refuse:
const { hasPermission, hasRole, hasFeature } = useMe();
{hasPermission("catalog.change_product") && <Button>Edit</Button>}
{hasFeature("new-editor") ? <NewEditor /> : <Editor />}Feature flags
A flag is on for a user when any of these holds:
| Field | Effect |
|---|---|
| enabled | On for everyone. |
| users | On for the listed users. |
| groups | On for users with one of the listed roles. |
| rollout_percent | On for that share of users, 0 to 100. |
The rollout bucket is sha256("<key>:<user id>") % 100: stable for a user, independent between flags, and raising the percentage only adds users, never swaps them.
Endpoints
Mounted at /api/rbac. Role and permission endpoints need is_staff or auth.change_group; flag management needs is_staff or the matching pleate_rbac.*_feature permission. All of them except features/me need a signed in session: an API key gets api_key_not_allowed.
Without superuser rights you can only put permissions you hold yourself in a role, edit, delete, give or take roles whose permissions you hold, never change your own roles, and never set or unset is_default.
| Method | Path | Purpose |
|---|---|---|
| GET | /rbac/roles | Page list, ?search= name, ?ordering=name. |
| POST | /rbac/roles | {name, description?, is_default?, permissions?: ["app.codename"]} |
| GET | /rbac/roles/<id> | {id, name, description, is_default, permissions} |
| PATCH | /rbac/roles/<id> | Any field; permissions replaces the set. |
| DELETE | /rbac/roles/<id> | 204. Also removes the role's object grants. |
| GET | /rbac/permissions | Every permission, page list, ?search= over codename, name, app and model. |
| POST | /rbac/users/<user_id>/roles | {role} → {roles: [names]} |
| DELETE | /rbac/users/<user_id>/roles | {role} in the body or ?role= → {roles} |
| GET | /rbac/features/me | {features: [keys]} enabled for the signed in user. Any user. |
| GET | /rbac/features | Page list, ?search=, ?enabled=, ?ordering=key,created_at,updated_at,rollout_percent. |
| POST | /rbac/features | {key, description?, enabled?, rollout_percent?, users?: [ids], groups?: [role names]} |
| GETPATCHDELETE | /rbac/features/<id> | Read, change or delete one flag. |
Error codes
Most are validation codes: the response is a validation_error and the code sits on the field in fields.
| Code | Status | When |
|---|---|---|
| role_taken | 400 field | A role with this name exists. |
| unknown_permission | 400 field | A permission string is not a valid app.codename. |
| unknown_role | 400 field | No role with this name. |
| unknown_user | 400 field | No user with this id. |
| feature_taken | 400 field | A flag with this key exists. |
| feature_disabled | 403 | Raised by HasFeature when the flag is off. |
| permission_not_held | 403 | A permission (or a role holding one) you do not have yourself. |
| permission_denied | 403 | Setting or unsetting is_default without superuser rights. |
| cannot_modify_self | 403 | Giving or taking one of your own roles. |
| api_key_not_allowed | 403 | The request used an API key. |
The admin registers Group (with its RoleInfo inline), RoleInfo, ObjectPermission and Feature. There are no settings.