Skip to content

Permissions in Edgy

Managing permissions is a crucial aspect of database-driven applications. Edgy provides a flexible and portable way to handle permissions, using database tables rather than relying solely on database users.

Permission Objects

Edgy's permission system is designed to accommodate various permission structures. Here's a breakdown of the key components:

Users

Users are the central entities in most applications. Permissions are typically associated with users through a ManyToMany field named users.

Groups

Groups allow you to organize permissions into sets that can be assigned to users. This feature is optional, but if used, the permission model must include a ManyToMany field named groups.

Model Names

Model names provide a way to scope permissions to specific models (e.g., blogs, articles). This feature is optional and is enabled by including a CharField or TextField named name_model.

Note: The model_ prefix is reserved by Pydantic, so name_model is used instead. If you only need object-specific permissions, you can check model names against objects directly.

Objects

Permissions can be assigned to specific object instances using ContentTypes, enabling per-object permissions. This is an optional feature. If name_model is not specified, permissions are checked against model_names in the ContentType.

To enable this, include a ForeignKey named obj that points to ContentType.

Usage

Edgy's permission models automatically detect the features you've enabled based on the presence of specific fields. This is why strict field naming conventions are important.

Edgy provides three additional manager methods for working with permissions:

  • permissions_of(sources)
  • users(...)
  • groups(...)

Parameters for users and groups

The users and groups methods accept the following parameters (except for permissions, all are optional):

  • permissions (str | Sequence[str]): The names of the permissions to check.
  • model_names (str | Sequence[str] | None): Model names, used if name_model or obj is present.
  • objects (Object | Sequence[Object] | None): Objects associated with the permissions.
  • include_null_model_name (bool, default: True): Automatically includes a check for a null model name when model_names is not None.
  • include_null_object (bool, default: True): Automatically includes a check for a null object when objects is not None.

Why Include include_null_model_name and include_null_object?

Setting obj or name_model to None allows you to broaden the scope of a permission, making it applicable to all objects or models.

Quickstart Example

Here's a basic example of a permission model:

import edgy
from edgy.contrib.permissions import BasePermission

models = edgy.Registry("sqlite:///foo.sqlite3")


class User(edgy.Model):
    name = edgy.fields.CharField(max_length=100, unique=True)

    class Meta:
        registry = models


class Permission(BasePermission):
    users = edgy.fields.ManyToMany(
        "User", embed_through=False, through_tablename=edgy.NEW_M2M_NAMING
    )

    class Meta:
        registry = models
        unique_together = [("name",)]


user = User.query.create(name="edgy")
permission = await Permission.query.create(users=[user], name="view")
assert await Permission.query.users("view").get() == user
await Permission.query.permissions_of(user)

It's recommended to use unique_together for the fields that uniquely identify a permission.

Advanced Example

This example demonstrates a permission model with all possible fields configured:

import edgy
from edgy.contrib.permissions import BasePermission

models = edgy.Registry("sqlite:///foo.sqlite3")


class User(edgy.Model):
    name = edgy.fields.CharField(max_length=100)

    class Meta:
        registry = models


class Group(edgy.Model):
    name = edgy.fields.CharField(max_length=100)
    users = edgy.fields.ManyToMany("User", through_tablename=edgy.NEW_M2M_NAMING)

    class Meta:
        registry = models


class Permission(BasePermission):
    users = edgy.fields.ManyToMany("User", through_tablename=edgy.NEW_M2M_NAMING)
    groups = edgy.fields.ManyToMany("Group", through_tablename=edgy.NEW_M2M_NAMING)
    name_model: str = edgy.fields.CharField(max_length=100, null=True)
    obj = edgy.fields.ForeignKey("ContentType", null=True)

    class Meta:
        registry = models
        unique_together = [("name", "name_model", "obj")]


user = User.query.create(name="edgy")
group = Group.query.create(name="edgy", users=[user])
permission = await Permission.query.create(users=[user], groups=[group], name="view", obj=user)
assert await Permission.query.users("view", objects=user).get() == user
await Permission.query.permissions_of(user)

Advanced Example with Primary Keys

Edgy's flexible overwrite logic allows you to use primary keys instead of unique_together:

import edgy
from edgy.contrib.permissions import BasePermission

models = edgy.Registry("sqlite:///foo.sqlite3")


class User(edgy.Model):
    name = edgy.fields.CharField(max_length=100)

    class Meta:
        registry = models


class Group(edgy.Model):
    name = edgy.fields.CharField(max_length=100)
    users = edgy.fields.ManyToMany(
        "User", embed_through=False, through_tablename=edgy.NEW_M2M_NAMING
    )

    class Meta:
        registry = models


class Permission(BasePermission):
    # overwrite name of BasePermission with a CharField with primary_key=True
    name: str = edgy.fields.CharField(max_length=100, primary_key=True)
    users = edgy.fields.ManyToMany(
        "User", embed_through=False, through_tablename=edgy.NEW_M2M_NAMING
    )
    groups = edgy.fields.ManyToMany(
        "Group", embed_through=False, through_tablename=edgy.NEW_M2M_NAMING
    )
    name_model: str = edgy.fields.CharField(max_length=100, null=True, primary_key=True)
    obj = edgy.fields.ForeignKey("ContentType", null=True, primary_key=True)

    class Meta:
        registry = models


user = User.query.create(name="edgy")
group = Group.query.create(name="edgy", users=[user])
permission = await Permission.query.create(users=[user], groups=[group], name="view", obj=user)
assert await Permission.query.users("view", objects=user).get() == user
await Permission.query.permissions_of(user)

Using primary keys in this way prevents permissions from changing their scope and introduces a slight overhead due to the use of primary keys as foreign keys.

Alternatively, you can overwrite name with a primary key field, which removes the implicit ID.

Practical Example: Automating Permission Management

This example demonstrates how to create a Permission object class that automates permission assignment.

from typing import Any
import logging

import edgy
from edgy.contrib.permissions import BasePermission
from sqlalchemy.exc import IntegrityError

# Import the User model from your app
from accounts.models import User

logger = logging.getLogger(__name__)

registry = edgy.Registry("sqlite:///foo.sqlite3")


class Group(edgy.Model):
    name = edgy.fields.CharField(max_length=100)
    users = edgy.fields.ManyToMany("User", through_tablename=edgy.NEW_M2M_NAMING)

    class Meta:
        registry = registry


class Permission(BasePermission):
    users: list["User"] = edgy.ManyToManyField(
        "User", related_name="permissions", through_tablename=edgy.NEW_M2M_NAMING
    )
    groups: list["Group"] = edgy.ManyToManyField(
        "Group", related_name="permissions", through_tablename=edgy.NEW_M2M_NAMING
    )
    name_model: str = edgy.fields.CharField(max_length=100, null=True)
    obj = edgy.fields.ForeignKey("ContentType", null=True)

    class Meta:
        registry = registry
        unique_together = [("name", "name_model", "obj")]

    @classmethod
    async def __bulk_create_or_update_permissions(
        cls, users: list["User"], obj: edgy.Model, names: list[str], revoke: bool
    ) -> None:
        """
        Creates or updates a list of permissions for the given users and object.
        """
        if not revoke:
            permissions = [{"users": users, "obj": obj, "name": name} for name in names]
            try:
                await cls.query.bulk_create(permissions)
            except IntegrityError as e:
                logger.error("Error creating permissions", error=str(e))
            return None

        await cls.query.filter(users__in=users, obj=obj, name__in=names).delete()

    @classmethod
    async def __assign_permission(
        cls, users: list["User"], obj: edgy.Model, name: str, revoke: bool
    ) -> None:
        """
        Creates a permission for the given users and object.
        """
        if not revoke:
            try:
                await cls.query.create(users=users, obj=obj, name=name)
            except IntegrityError as e:
                logger.error("Error creating permission", error=str(e))
            return None

        await cls.query.filter(users__in=users, obj=obj, name=name).delete()

    @classmethod
    async def assign_permission(
        cls,
        users: list["User"] | Any,
        obj: edgy.Model,
        name: str | None = None,
        revoke: bool = False,
        bulk_create_or_update: bool = False,
        names: list[str] | None = None,
    ) -> None:
        """
        Assign or revoke permissions for a user or a list of users on a given object.

        Args:
            users (list["User"] | "User"): A user or a list of users to whom the permission will be assigned or revoked.
            obj (edgy.Model): The object on which the permission will be assigned or revoked.
            name (str | None, optional): The name of the permission to be assigned or revoked. Defaults to None.
            revoke (bool, optional): If True, the permission will be revoked. If False, the permission will be assigned. Defaults to False.
            bulk_create_or_update (bool, optional): If True, permissions will be created or updated in bulk. Defaults to False.
            names (list[str] | None, optional): A list of permission names to be created or updated in bulk. Required if bulk_create_or_update is True. Defaults to None.
        Raises:
            AssertionError: If users is not a list or a User instance.
            ValueError: If bulk_create_or_update is True and names is not provided.
        Returns:
            None
        """

        assert isinstance(users, list) or isinstance(
            users, User
        ), "Users must be a list or a User instance."

        if not isinstance(users, list):
            users = [users]

        if bulk_create_or_update and not names:
            raise ValueError(
                "You must provide a list of names to create or update permissions in bulk.",
            )
        elif bulk_create_or_update:
            return await cls.__bulk_create_or_update_permissions(users, obj, names, revoke)

        return await cls.__assign_permission(users, obj, name, revoke)

This example shows how to automate permission management by consolidating permission-related logic in a single class. This allows you to create, manage, and revoke permissions efficiently.

Note: This example is for illustrative purposes and should be adapted to fit your specific application requirements.