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 ifname_model
orobj
is present.objects
(Object | Sequence[Object] | None): Objects associated with the permissions.include_null_model_name
(bool, default: True): Automatically includes a check for anull
model name whenmodel_names
is notNone
.include_null_object
(bool, default: True): Automatically includes a check for anull
object whenobjects
is notNone
.
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.