Permission predicates
The permission management blueprint introduces the concept of permission predicates, that are functions that check whether a user can perform an operation on a resource, based mainly on the roles their groups have on it.
This document is about the design of permission predicates.
This is a hard design problem because there are many ways of implementing permission checking functions, all in principle equally valid.
Because permission checking is just being introduced in Debusine, we have little data on how they would most efficiently be used.
Before proposing an implementation strategy, this document attempts to map the ways permission predicates are expected to be used, and restrict their possible initial uses in order to narrow down the range of possible implementations.
See Initial roles for an initial idea of roles and associated actions, which is useful as context for this design discussion.
Mapping the problem
Roles can be delegated
Checking for permissions on a resource does not only depend on the roles the user has on that resource, because roles can be delegated to other resources:
Having certain roles on a scope gives some permissions over its workspaces, collections, artifacts, workflows, and so on, without needing direct roles over them.
Having roles on a workspace gives permissions over collections, artifacts, workflows, and so on, without needing direct roles over them.
In other words, permission checks on a resource may take into consideration whether the user has roles on a containing resource.
In fact, at least at the beginning most permission checking on Debusine resources will rely on delegating permissions from roles on containing resources, since user stories that require more nuanced delegation of permissions are not in our immediate implementation range.
Note that there may be special situations where delegation is disabled, such as embargoed workspaces not visible by default except to their direct members, to prevent accidental data exposure. Delegation would still be a main way of operation, to avoid the complexity of micromanaging group roles on each resource.
We do not only check roles
On top of roles, we also check:
User.is_superuser
andcontext.disable_permission_checks
, which disable permission checks. Note that we may not want to useis_superuser
directly, and only treatis_superuser
as a “sudoer” marker that allows the user to activate superuser powers only when needed: that way an admin can use the same account for ordinary use, without fears of accidental exercise of powers.Workspace.public
, which gives permissions on a workspace which may be different from the one given by roles, and the visibility of collections, artifacts, workflows, and so on may delegate to it.
We test permissions on individual resources
Predicates can be used to test permissions on an individual resource: can the user display the contents of the collection being requested? Can the user write to it?
We list resources for which we have permissions
Predicates can be used to filter resources to display for UI navigation or selection lists in forms. For example:
list visible workspaces
list executable workflows
list writable collections as targets for a workflow
list groups of the current users
Testing individual resources
Most per-resource permission checking will happen based on the current application context: “can the current user, in the current scope and workspace, perform the given action on this resource?”
This is likely to happen frequently over the course of a request, and can benefit from caching to avoid hitting the database on each resource to be checked, especially since permissions on containing resources are likely to be sufficient to grant permissions in most cases via delegation.
This means that permission checking on single instances can use shortcuts to avoid hitting the database when the current user, scope and so on match the current context, and use the filtering code to hit the database otherwise.
Filtering code can be turned into checking code by concatenating with
exists
:
if Model.objects.can_display(user).filter(pk=obj.pk).exists():
..
Filtering resources
Filtering resources needs to hit the database in any case, so it is less likely to benefit from shortcuts based on cached data for the current context.
Depending on use cases, one may or may not need to restrict results to the current scope, workspaces and so on.
Resource querysets can have in_current_*()
methods, like
in_current_scope()
and in_current_workspace()
, that filter results
using different aspects of the current application context.
Caching and invalidation
To avoid the hard problem of cache invalidation, caches are only invalidated at the end of the request, and therefore group role or membership changes only come into action on the next request.
Implementation proposal
Predicate API
Checking individual resources
instance.can_<predicate>(self, user: User | AnonymousUser) -> bool
Test the predicate on a model instance. Some examples: can_display
,
can_create_workspace
, can_add_artifact
.
To test the predicate on the current context, one passes context.user
as
the user, and predicate code can check if user == context.user
to apply
shortcuts if needed.
Filtering resources
Model.objects.can_<predicate>(user: User | AnonymousUser) -> QuerySet[Model]
This manipulates a queryset, and so can be further refined with other methods
like in_current_scope()
, .filter(pk=obj.pk).exists()
(to test on single
instances), and so on.
Behind the scenes
Predicate functions are implemented in a custom QuerySet
, and use the
.as_manager()
method to propagate them to a model’s manager:
objects = ResourceQuerySet.as_manager()
or:
objects = ResourceManager.from_queryset(ResourceQuerySet)
Predicate functions are decorated with @permission_check
or
@permission_filter
decorators, which provide common behaviour such as
handling context.disable_permission_checks
and active superuser privileges.
The test suite infrastructure provides an @override_permissions
decorator,
similar to Django’s @override_settings
, to mock permission checks.