Backend

Roles, permissions and feature flags

Roles on top of Django groups, object level permissions, percentage rollouts and DRF permission classes that compose.

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

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.

ClassPasses 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.
catalog/views.py
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:

tsx
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:

FieldEffect
enabledOn for everyone.
usersOn for the listed users.
groupsOn for users with one of the listed roles.
rollout_percentOn 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.

MethodPathPurpose
GET/rbac/rolesPage 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/permissionsEvery 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/featuresPage 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.

CodeStatusWhen
role_taken400 fieldA role with this name exists.
unknown_permission400 fieldA permission string is not a valid app.codename.
unknown_role400 fieldNo role with this name.
unknown_user400 fieldNo user with this id.
feature_taken400 fieldA flag with this key exists.
feature_disabled403Raised by HasFeature when the flag is off.
permission_not_held403A permission (or a role holding one) you do not have yourself.
permission_denied403Setting or unsetting is_default without superuser rights.
cannot_modify_self403Giving or taking one of your own roles.
api_key_not_allowed403The request used an API key.

The admin registers Group (with its RoleInfo inline), RoleInfo, ObjectPermission and Feature. There are no settings.