PonyJS security and permissions¶
When we send information from the backend, there might be some objects or object attributes that we don’t want to show at the frontend. Sometimes we don’t want to allow editing of some objects or attributes, or we want to give this permission only to a specific group of users. In other words, there needs to be a method to protect data from unauthorized view and edit. For this purpose you should use PonyJS security and permissions.
The PonyJS Security and Permissions workflow is divided into two major parts:
- Setting up permission rules
- Applying the permission rules onto the objects which are sent or received from the frontend
Setting up permission rules¶
For setting up a permission rule, you should specify a list of actions followed by conditions on when those actions are permitted.
Usually, it is convenient to create a separate file permissions.py
where you put all your permission rules and then place it along with the file models.py
where you keep your entity declarations. In your program you import the file with the permissions after the file with entity declarations and this way apply your permission rules. By default nothing is permitted, so if you forget to import the file with permission rules, Pony will raise the PermissionError
exception once you’ll try to get JSON payload using the to_json()
method.
Here is the permission rules for our Forum:
# anybody can view User, Section, Tag objects
# except User.password, User.email attributes
with db.permissions_for(User, Section, Tag):
allow('view', group='anybody').exclude(User.password, User.email)
# anybody can view public (not deleted) Message object
with db.permissions_for(Message):
allow('view', group='anybody', label='public')
# admin can manage sections
with db.permissions_for(Section):
allow('edit create delete', group='admin')
# author can manage their messages
with db.permissions_for(Message):
allow('edit create delete', role='author')
# voter can view their votes
with db.permissions_for(Vote):
allow('view', role='voter')
with db.permissions_for(Message):
# admin can delete any message
allow('delete', group='admin')
# moderator can delete messages in their sections
allow('delete', role='moderator')
Now let’s get into the details.
Permissions¶
There are four standard permissions: view
, edit
, create
, delete
.
view
- allows viewing an object at the frontendedit
- allows editing an object at the frontend. This permission implies theview
permission - if you allow editing, it means that an object can be viewedcreate
- allows creating an object at the frontenddelete
- allows deleting an object at the frontend
Groups, Roles and Labels¶
Pony uses the concept of groups, roles and labels for setting permissions.
admin
, user
. There is a default group – anybody
. Every user is a member of this group. If no group is assigned to a user, this user still belongs to the group anybody
.The set of user groups is assigned in the beginning of the db_session
and cannot be changed till the end of current db_session
.
A group is a characteristic of a user.
For example, using the concept of roles, we can allow a user updating the Post
objects only if this user is the author of these posts. Examples of roles: author
, moderator
, voter
.
A role is a characteristic of a user in relation to an object.
public
.A label is a characteristic of an object.
Assigning rules to entities¶
By default all permissions on objects are denied and in order to permit, you have to create allow
rules. You can create more than one rule for any entity. All rules for each entity will be summarized. The order of the rules doesn’t matter.
The allow
rule can be assigned to entities with the help of the permissions_for
context manager. It is a method of the Database
object:
with db.permissions_for(User, Section, Tag):
allow('view', group='anybody').exclude(User.password, User.email)
If you need to apply the allow rule only to some entity attributes, not all of them, you can do so with the help of the exclude
method. In parenthesis you specify the list of attributes which should be excluded from the rule.
In the example above we allow to view User
,``Section``, Tag
objects at the frontend to anybody. Except the User.password
and User.email
attributes.
Another example:
with db.permissions_for(Message):
# admin can delete any message
allow('delete', group='admin')
# moderator can delete messages in their sections
allow('delete', role='moderator')
In this example we allow the owner of the account to edit all attributes of the User
object. Users with the employee
role can view all attributes, and everyone else can view all attributes except of the User.email
attribute.
If you use inheritance for your entities, you need to declare permissions for the base entity only.
Hiding entity attributes by default¶
Sometimes we want to be sure that some attributes will never be passed to the frontend, even if the permission rules mistakenly allow it. In this case we can use the hidden
option of an attribute:
class User(db.Entity):
username = Required(str)
password = Required(str, hidden=True)
This option has a preference over the permission rules and, when set to True
guarantees that this attribute will never be passed to the frontend.
Applying the permission rules onto the objects which are being sent or received from the frontend¶
Pony checks the permissions during preparing objects for sending to the frontend (to_json()
method) and during saving object updates received from the frontend (from_json()
method). Pony goes through the list of the objects and checks permissions for each object.
For making a decision if an object can be passed through, Pony needs to get the following information:
- what groups the current user belongs to
- what roles the current user has against each object
- what labels assigned to each object
In order to provide Pony with this information, you need to write functions and decorate them with special decorators:
@groups_getter(user_class)
@roles_getter(user_class, obj_class)
@labels_getter(obj_class)
Pony will call these functions whenever it needs to get groups, roles and labels associated with the current user and objects that are going to be sent or received from the backend. Pony applies the information received from these functions to the permission rules and makes a decision if it can allow the requested action(view, edit, create or delete) on a particular object.
But first of all, before Pony calls these functions, we need to set current user for the HTTP request.
Setting current user¶
The current user is usually set in the beginning of processing a request from a user. The username is usually stored in cookies. Using this username we can extract the User object from the database and set it as the current user:
set_current_user(user)
At the end of the request processing you need to reset the current user. This can be done by passing None
as the user object:
set_current_user(None)
Getting current user at the frontend¶
The to_json()
method adds the JSON representation of the current user to the resulting JSON payload. At the frontend this object is stored under the pony.currentUser
variable.
Using decorators for getting groups, roles and labels¶
When Pony sends objects to the frontend using the method to_json()
it needs to check if the current user has a permission to view
each of those object. For this purpose Pony calls the function decorated with the @groups_getter
decorator. This function should return a single group, or a list of groups which this user belongs to. Pony caches the result and keeps it till the end of the db_session.
Then Pony iterates over the objects go be sent to the frontend and calls functions decorated with the @roles_getter
and @labels_getter
decorators. Then Pony applies the result, received from these functions to the permission rules declared earlier. If Pony encounters an object that doesn’t have a corresponding rule which allows to view
this object, it raises the PermissionError
exception.
When Pony receives objects from the frontend using the method from_json()
, it performs the same steps. The only difference is that instead of checking the view
permission, it checks the permission for edit
, create
or delete
, depending on what was done with an object at the frontend.
In the parenthesis following the decorator name, you need to write a class, which will work as a filter for this function. When Pony checks permissions for an object, it calls only those functions which have a class of this object (or its ancestor) specified in the decorator. In the same time, the function will receive the object itself, not the class.
@groups_getter(user_class)
- decorates functions used for getting user groups
@roles_getter(user_class, obj_class)
- decorates functions used for getting user roles
@labels_getter(obj_class)
- decorates functions used for getting object labels
You can declare more then one function decorated with the same decorator and having the same filter. Pony will call all functions which correspond to a particular object. All returned results will be combined together. The function declared with one of these decorators can return:
- A string, meaning that this group, role or label will be added to the resulting list of groups, roles and labels
- A list of strings, meaning that each string from this list will be added to the resulting list
None
, meaning that this function adds nothing
You can call these getters functions recursively.
default_filter¶
Sometimes it is convenient to have a filter which would be applied to all queries automatically. In our Forum data model the Message
entity has the deleted_at
attribute. The idea here is that when the message needs to be deleted, we don’t delete it, but mark it deleted by assigning the current datetime to the deleted_at
attribute. When we write a query which extracts Message
objects from the database, we should remember about this and always add the and m.deleted_at is None
condition, in order not to show deleted messages. Another option would be to define a default_filter
:
@default_filter(Message)
def not_deleted(obj):
return obj.deleted_at is None
Here we declare a function and decorate it with the @default_filter
decorator. In the parenthesis we specify the entity where we apply the default filter. Once it is declared, all queries that select instances of the specified entity will have the default filter applied.
Note
If your query returns something other than the entity instances, say an attribute, or a tuple, this filter will not be applied.
The function that is decorated with the @default_filter
receives an instance of the entity. The body of this function consists of the return
keyword and the condition.
If you need to apply the default filter only to specific user groups, you can use the except_groups
option:
@default_filter(Message, except_groups=['admin', 'moderator'])
def not_deleted(obj):
return obj.deleted_at is None
In the example above, the default filter wouldn’t be applied if the user belongs to the admin
or moderator
groups.
Also, you can disable the default filter at any moment by using the default_filters_disabled
context manager or decorator:
with default_filter_disabled():
posts = Post.select(lambda p: p.author == User[1])
The query example above will return all posts of the User[1]
.