Package: django-csvpermissions
CSV Permissions Module for DjangoCSV-based permissions for Django.ReadMotivation / Rationalefor why this project exists.System RequirementsTested with django 2.2 and 3.2Pull requests accepted for other versions, but at minimum we test against current LTS versionsPython >=3.6 (no python 3.5 support)CSV PermissionsThecsv_permissionsmodel works as follows:Every user has a "user type" (the equivalent ofdjango.contrib.auth'sGroup)A CSV file that defines a permission matrix is used to define what permissions eachuser_typehasQuick StartIn your django settings:Addcsv_permissions.permissions.CSVPermissionsBackendtoAUTHENTICATION_BACKENDSsetCSV_PERMISSIONS_PATHSwhich is an array/tuple ofstr/pathlib.Pathpointing to the CSV files you want to use to define your permissions.
Multiple files will be merged.
The CSV files order does not matter: an error will be raised if the files are
inconsistent.
If a permission or user type is missing from one CSV file then this is not considered
inconsistent, but a blank cell vs a filled cell is inconsistent.SetCSV_PERMISSIONS_RESOLVE_EVALUATORSto"csv_permissions.evaluators.default_resolve_evaluators"Autoreloadcsv_permissionscaches the data read from the CSV permissions file on server start.
During development this means you have to manually restart the dev server when you make changes.
You can hook into django's autoreloader to automatically reload when the CSV file is changed:In one of yourdjango app configs:defadd_csv_permissions_watcher(sender:django.utils.autoreload.StatReloader,**kwargs):"""In dev we want to reload if the csv permission file changes"""sender.extra_files.add(settings.CSV_PERMISSIONS_PATHS)classMySiteAppConfig(AppConfig):name="my_site"verbose_name="My Site"defready(self):ifsettings.DEBUG:fromdjango.utils.autoreloadimportautoreload_startedautoreload_started.connect(add_csv_permissions_watcher)If you're usingrunserver_plusfromdjango-extensionsyou can add your CSV files tosettings.RUNSERVER_PLUS_EXTRA_FILES.The CSV FileAn example permission file:Model,     App,           Action,            Is Global, admin, assistant, customer

# Comment lines and blank lines will be ignored

# The horizontal column alignment is just for readability:
#  leading/trailing spaces will be stripped from each cell

Publisher, library,       add,               yes,       yes,   no,        no
Publisher, library,       view,              no,        all,   no,        no
Publisher, library,       change,            no,        all,   no,        no
Publisher, library,       delete,            no,        all,   no,        no

Book,      library,       add,               yes,       yes,   yes,       no
Book,      library,       view,              no,        all,   all,       no
Book,      library,       change,            no,        all,   all,       no
Book,      library,       delete,            no,        all,   all,       no

Loan,      library,       add,               yes,       yes,   yes,       yes
Loan,      library,       view,              no,        all,   all,       no
Loan,      library,       change,            no,        all,   all,       no
Loan,      library,       delete,            no,        all,   all,       no

# The model column can be blank. Note that the customer column here is also
# empty; see below for the difference between this and "no"

,          library,       report_outstanding,yes,      yes,   yes,
,          library,       report_popularity, yes,      yes,   yes,The first 4 columns define the permission details.
These will be used to resolve the permission code name (seePermission Names).Modelis used to resolve the permission name but is otherwise not used. There is no checks that objects passed to thehas_perm()actually match the correct type.Appis used to resolve the permission name and model.Actionis an arbitrary identifier that is used to resolve the permission name.Is Globalwhether the permission is global or per-object (see "Global Permission" section below).
Right now you must provide a model ifIs Globalis false however this restriction may be
relaxed in future.EvaluatorsThe next columns define permission "evaluators" for eachuser typeBuilt-in evaluators are:all- user has permission for all objects. Will raise an error if an object is not passed tohas_perm()yes- user has permission globally. Will raise an error if an object is passed tohas_perm().no-- user does not have permission (global or per-object)(empty cell) -- user permission is not defined.
If another CSV file defines this user/permission pair then that will be used.
If no CSV file defines this user/permission pair then the evaluator will be
treated as""and by default theresolve_empty_evaluatorwill treat this as no permission granted.The distinction betweenallandyesis thatallis a per-object
permission andyesis aglobal permission.Global PermissionsUnlike vanilla django permissions, by defaultcvs_permissionsimposes a hard
distinction between global and per-object permissions.If you pass an object in a permission check against a permission withIs Global==yesin the CSV file then aValueErrorwill be raised.If youdon'tpass an object to a permission check against a permission withIs Global==noin the CSV file then aValueErrorwill be raised.TheCSVPermissionsBackendprovides anis_global_perm()method to query
whether a permission is global or per-object:# example of querying whether a permission is globalprint("foo-bar is a global permission"ifCSVPermissionBackend().is_global("foo-bar")else"foo-bar is a per-object permission")Custom EvaluatorsBy default putting anything other than a built-in evaluator in a CSV permissions file
will raise an error.You add your own permission evaluators by defining "evaluator resolver"
functions which ingest a CSV cell value and returns a permission evaluator.
If the resolver does not recognise something it should returnNoneand the
next resolver in the list will be called.# in settings.pyCSV_PERMISSIONS_RESOLVE_EVALUATORS=(# sanity check that non-global permissions have a model'csv_permissions.evaluators.resolve_validate_is_global_model',# custom validators (examples below)'my_app.evaluators.resolve_evaluators',# 'all'/'yes'/'no''csv_permissions.evaluators.resolve_all_evaluator','csv_permissions.evaluators.resolve_yes_evaluator','csv_permissions.evaluators.resolve_no_evaluator',# If you remove the empty evaluator then "" will fall through to the# remaining evaluator(s). This can be used in combination with# CSV_PERMISSIONS_STRICT to ensure that there are no blank cells in a CSV# file. Note that cells not present in any file due to different headers# still won't be processed.'csv_permissions.evaluators.resolve_empty_evaluator',# normally if nothing matches an exception will be thrown however it# can be more convenient (especially in early phases of development )# to issue a warning during CSV parsing, and then throw a# NotImplementedError() when the permission is evaluated'csv_permissions.evaluators.resolve_fallback_not_implemented_evaluator',)# if you don't have any customisations you can point to a list/tuple# that is defined elsewhere; if you don't set it then this is the default setting:#CSV_PERMISSIONS_RESOLVE_EVALUATORS = "csv_permissions.evaluators.default_resolve_evaluators"# for compatibility with csv_permissions 0.1.0#CSV_PERMISSIONS_RESOLVE_EVALUATORS = "csv_permissions.legacy.resolve_evaluators"The following code will define some custom evaluators:'if_monday'grants all access on mondays.'all_caps'grants access to all objects that have anamefield containing
all uppercase.Inmy_app.evaluators:importdatetimefromtypingimportOptionalfromcsv_permissions.typesimportEvaluatorfromcsv_permissions.typesimportUnresolvedEvaluatordefevaluate_if_monday(user,obj=None):returndatetime.datetime.today().weekday()==0defevaluate_all_caps(user,obj=None):ifobjisNone:raiseValueError("'all_caps' cannot be used as a global permission.")try:returnobj.name.isupper()exceptAttributeError:returnFalsedefresolve_evaluators(details:UnresolvedEvaluator)->Optional[Evaluator]:ifdetails.evaluator_name=="if_monday":returnevaluate_if_mondayifdetails.evaluator_name=="all_caps":ifdetails.is_global!=False:raiseValueError("'all_caps' cannot be used as a global permission.")returnevaluate_all_capsreturnNoneNote that evaluator names do not have to be static strings: you could implement
something that understood'all_caps:True'and'all_caps:False'for exampleUser TypeUser Types are thecsv_permissionsequivalent of django's user Group.
A user has a single user type, and from that is granted the set of permissions
that this user type has in the CSV file.The default user type is obtained from the user'suser_typeattribute.If this doesn't exist or you need custom logic then you can setsettings.CSV_PERMISSIONS_GET_USER_TYPEto a function that takes a
user and returns the user type. If the function returnsNoneor an empty
string then the user will have no permissions.Custom example where the user type field is stored on a user Profile record
instead of the User record:Insettings.py:CSV_PERMISSIONS_GET_USER_TYPE='my_site.auth.get_user_type'Inmy_site/auth.py:fromtypingimportOptionalfromdjango.contrib.authimportget_user_modelUser=get_user_model()defdefault_get_user_type(user:User)->Optional[str]:try:returnuser.get_attached_profile().user_typeexceptAttributeError:# user might be an AnonymousUser# user_type==None will be treated as a user with no permissions# if you do want to grant AnonymousUser selective permissions then return a placeholder stringreturnNoneUnrecognised PermissionsIfsettings.CSV_PERMISSIONS_STRICTis true then querying a permission
(oruser_type) that is not in the CSV will raise aLookupError.This is not set by default as it prevents the ability to use multiple
authentication backends for permission checks. If you are usingcsv_permissionsexclusively for permission checks then it can be helpful to catch typos.Permission NamesBy defaultcsv_permissionswill use the same permission name format as django:<app label>.<action>_<model>You can optionally setsettings.CSV_PERMISSIONS_RESOLVE_PERM_NAMEto the fully qualified name of a function to
resolve permission names to whatever pattern you want.Insettings.py:CSV_PERMISSIONS_RESOLVE_PERM_NAME='my_site.auth.resolve_perm_name'Inmy_site/auth.py:fromtypingimportOptionalfromtypingimportTypefromdjango.appsimportAppConfigfromdjango.db.modelsimportModeldefresolve_perm_name(app_config:AppConfig,model:Optional[Type[Model]],action:str,is_global:bool)->str:# here's an implementation that is almost the same as django, but# uses - as a separator instead of _ and .ifmodelisNone:returnf"{app_config.label}-{action}"else:returnf"{app_config.label}-{action}-{model._meta.model_name}"Note the handling of the case where a permission has no model.
Examples of this can be seen in thereport_outstandingandreport_popularitypermissions in thesample CSV file.Full Settings ReferenceCSV_PERMISSIONS_GET_USER_TYPEOptional. Function to get the user type from a user. Defaults to returninguser.user_type.DetailsCSV_PERMISSIONS_PATHSRequired. List/tuple of CSV file names to use for permissions.Alternately as a shorthand if you only have one CSV file you can instead setCSV_PERMISSIONS_PATHto that single file.CSV_PERMISSIONS_RESOLVE_EVALUATORSRequired. A list/tuple of functions to resolve evaluators, or a string
that will be resolved to a module attribute that is expected to contain a
list/tuple of functions to resolve evaluators.DetailsCSV_PERMISSIONS_RESOLVE_PERM_NAMEOptional. string or function. Defaults to'csv_permissions.permissions.default_resolve_perm_name'.DetailsCSV_PERMISSIONS_STRICTOptional. boolean. Defaults toFalse.Will cause the CSVPermissionsBackend to throw an error if you try to query an
unrecognised permission or user type.DetailsChangelogSeeCHANGELOG.mdDevelopmentNote that due to pip/poetry/distutils issues you can't easily install dev versions directly from github with pip (works fine with poetry though)https://github.com/python-poetry/poetry/issues/761#issuecomment-521124268proposed solution appears to work but actually installs the package as "UNKNOWN"https://github.com/python-poetry/poetry/issues/3153#issuecomment-727196619dephell might have worked but there's no homebrew package & it's no longer maintainedRelease ProcessPoetry ConfigAdd test repositorypoetry config repositories.testpypi https://test.pypi.org/legacy/Generate an account API token athttps://test.pypi.org/manage/account/token/poetry config pypi-token.testpypi ${TOKEN}On macs this will be stored in theloginkeychain atpoetry-repository-testpypiMain pypi repositoryGenerate an account API token athttps://pypi.org/manage/account/token/poetry config pypi-token.pypi ${TOKEN}On macs this will be stored in theloginkeychain atpoetry-repository-pypiPublishing a New ReleaseUpdate CHANGELOG.md with details of changes and new versionRunbin/build.py. This will extract version from CHANGELOG.md, bump version inpyproject.tomland generate a build for publishingTag with new version and update the version branch:ver=$( poetry version --short ) && echo "Version: $ver"git tag v/$vergit push --tagsTo publish to test.pypi.orgpoetry publish --repository testpypiTo publish to pypi.orgpoetry publish