Changelog¶
Unreleased¶
- The example shop speaks Persian - its menu, its orders, their fields and
statuses, and its scopes - so the documentation's Persian pictures are
Persian throughout.
nox -s messagesholds its catalogue too.
0.2.1 - what the pictures showed¶
What the pictures of the admin site showed, for the documentation. Nothing a project wrote changes.
- The access pages say their own words in Persian - the grid's column headings, the library's own permissions ("Can create access") and the apps' names were in English.
- The access pages' breadcrumbs are Django's bar, on Django 6.1 too, which styles only its new markup.
- The rule builder shows its operators in words - "is one of", not
is_one_of- in the reader's language. - A bundle's grid fits a 1280-pixel screen. A model's other permissions - approving, printing, a field's - run on a row of their own under its four, where they were pushed off the side.
- Somebody who holds a bundle by their role is on every access page. The people page and the overview's count left them out, the list of bundles counted nobody for a bundle a role starts people with, its own page read "Nobody holds it", and deleting it was shown as taking from nobody. They are listed now, marked from their role.
- A field protected in code says, in Persian, where it is unprotected - "only in code" - where it read as if it had been unprotected there.
- The permissions page is named in the library's own Persian word - the menu read Django's word for it, and every other page the library's.
0.2.0 - the security review¶
Found by a security review of 0.1.0. It changes what 0.1.0 did in ways a project may notice - each is marked Changed - so it is released as 0.2.0, not as a patch. Still alpha: the API may change before 1.0.
- The page of a user opens in the admin site. Adding or changing one was
a server error with Django's
UserAdmin, for everybody. - A superuser's account is the superuser's alone to change or delete in the admin site - its password too. Before, anybody who could change users could set a superuser's password and sign in as them.
- A field roles are read from is written by the superuser only in the
admin site, so nobody gives themselves a role with
AttributeResolver. A resolver of the project's own names its fields withRoleResolver.fields(). -
The last person able to manage access is not switched off or deleted on the user page.
-
A scope holds for what is written. A row created outside the scope
createwas given in, or edited out of the rows the person reaches, is refused and the write undone - in Django's views, DRF, and the admin site.clearance.writes.require_within_scopeasks the same in a view that saves by hand. Changed: a grant ofcreatewith no scope now reaches the model'sdefault_scope, as every other grant does - so no rows on aScopedQuerySetthat names none. Givecreatea scope. - An inline and the editable list change only the rows the person may edit, and an inline deletes only those they may delete. Before, every row they could view was theirs to change there.
-
An admin action naming Django's
permissions=runs over the rows those permissions reach, not every row the person can see. -
The safety rules hold round four more ways. Each was open to somebody managing access who held nothing of what they changed:
- changing the rows of a rule scope needs every permission granted in it, on every row;
- giving back a permission that was taken away needs holding it, with the scopes the person's bundles give it in;
- letting a status action write from more values is the superuser's and
the
ACCESSfile's - changed: somebody not holding it could; - giving a person under a ceiling a second role that widens or lifts it
needs
role_ceiling_edit. -
A bundle a person starts with by their role is one they hold: they do not change it.
-
A protected field is not learnt another way. Without its view permission it was still read, or its value narrowed down, through:
- a filter typed into the admin site's address bar (
?price__gte=), and the date hierarchy - both refused now; - a serializer nested in a protected one, written out or made by
depth; - DRF's
SearchFilterandOrderingFilternaming it, on a view withProtectedSerializerMixin(ScopedModelViewSethas it); - a proxy of its model, or a model inheriting it from a parent with its own table - it has the one pair of permissions on all of them.
- A value is refused under whatever name it is sent: a serializer field
with
source='status'could write a value a status action claims, and a relation could be written under its column's name (officer_id). -
A form in the admin site offers only the related rows the person may view, and the one the row holds already. Changed: choosing a related row there needs
viewon its model. -
visible()asks the queryset. A view, the admin site and a policy calledvisible(), which never asked aScopedQuerySetits ownvisible_to- soSignedInReadQuerySetshowed a view nothing, and an override that narrowed was ignored. They get the queryset's answer now. - A scope answering with a queryset keeps the rows it was asked about. Alone, it replaced the queryset, and whatever the view had filtered by - a tenant, say - was gone.
- E007 refuses a backend built on
ModelBackend, not that one name only: listed before the library's, it handed back every revocation. with_permanswers ashas_permdoes - without whoever a permission was taken from or whose ceiling stops it, with whoever starts with it.- The pages answer rather than fail. A bundle's key that is no number is a 404; a name longer than its column is refused, saying so - SQLite kept it whole and PostgreSQL answered with a server error.
- A rule comparing with
Noneis refused when saved: it was kept as'', and reached nothing from then on.is_emptysays it. -
Fewer queries. The overview and the actions page read the database a fixed number of times (about 90 before, and one more for each bundle; 19 now), and saving a bundle no longer reads it once for each permission.
-
A refusal is said in the reader's language. What the safety rules refuse, and what is wrong with a rule, a status action, a protected field or a name, was said in English whatever the language of the page. Each is marked for translation and said in Persian;
AccessRefused.rulestill names the rule for code. -
A protected serializer takes only the related rows the person may view, as a form in the admin site does. Changed: an API choosing a related row needs
viewon its model; reference data everybody chooses from reads throughSignedInReadQuerySet, as the example's branches do. - A permission another model has is not made again. A field's permission, or a status action's, with the codename of another model's in the same app would be that one: refused in the admin site, and at start-up for a field protected in code (E012).
- Two changes that could each leave nobody managing access wait their turn: the check locks the permission's row, where the database can.
- Migration 0006 is undone without loss: the rows go back into the
columns. Going forward, a condition that compared with
Nonebecomesis_empty. - Smaller ones. A yes or no sent as a form sends it (
on,true) is read as one, where a status action claims it. A rule on a generic relation is refused rather than failing. A status action in theACCESSfile whosetoorfromis not a list is a problem said, not a traceback. A sync skipped on a test database says so.
And what the review's fixes left:
- Rows sent together stay within the scope too. A list created at once
(
many=True) was saved row by row past the check a single row met; each row is now asked, and the list is saved whole or not at all. - A queryset's own
visible_tomay callvisible(self, ...), as 0.1.0's did: inside it,visibleanswers by the grants. It called the override again, without end. - An API keeps the related row a row holds already, though the person may not view it - an edit to a payment whose loan they cannot see was refused. The guard read a field's source before DRF had set it.
with_permcosts the same however many people hold the permission: the ceiling is asked of everybody at once, not of each in turn.- Every line of the library is run by a test, and the suite fails below
that (
fail_under = 100).
And what the review had left open:
- An account is changed or deleted only by somebody holding at least what
it holds. Whoever could change users set the password of a manager of
access and signed in as them; only a superuser's account was guarded.
Asked in
policy.allows, so every door asks it. Changed: a helpdesk that resets passwords holds what the accounts it resets hold. - A delete takes nothing the person may not delete. Deleting a loan
through the API deleted its payments for somebody allowed to delete none.
A cascade into a model the library guards is asked in DRF's
perform_destroy, Django'sDeleteViewand the admin site;clearance.writes.require_deletableasks the same by hand. - A rule reaching another model needs its writer to view all of it. A
condition on
applicant__emailread people for somebody who may see none. - The permission class reads a renamed field by what it writes. A view
choosing an unprotected serializer at run time - out of E003's sight - let
decision, withsource='status', write a status. - django-filter's
DjangoFilterBackendfilters only by readable fields on a view withProtectedSerializerMixin, as DRF's own filters do. -
The guide says what a long-lived process does with what is kept on a user: read them again, or
forget_grants(user). -
Found by a check of the fixes above, and held:
- the editable list of accounts leaves an account holding more than the person alone - on Django 5.2 it changed a manager's e-mail address, which is a way into their account;
- nobody writes, on their own account, a field a rule scope reads as the
person's (
region, for "the person's region") - in the admin site, a protected form, or an API: it is which rows they reach; - Changed: an account is outranked by one holding the same scope by
name only when that scope reaches the same rows for anybody. Two people
holding "their branch" each hold a branch of their own; changing the
other's account needs the permission in
all; - Changed: a delete asks every model a cascade reaches that has a
deletepermission, not only the models with scopes or declared permissions. Deleting a loan needsdeleteon its statements too.
And what a check of those found:
- An inline and the editable list ask a policy's own hook of each row - "a delivered order is never edited". They asked the scope alone, so a hook refusing a row was passed round: in an inline on every Django, in the editable list before 6.1.
PERSON_FIELDSnames the fields of the user a code scope reads -('branch',)forQ(branch=user.branch_id)- and they are locked on a person's own account, as the fields a rule scope reads are. The library cannot read a scope written in code.CASCADE_UNASKED_APPSnames the apps a delete takes with it unasked - an app keeping a token or an address for each account, whosedeletenobody is given. A model the library guards is asked all the same.
0.1.0 - the first release¶
Alpha: every part is built and tested, but not yet used in a project with real models, so the API may still change before 1.0.
- Bundles - Django's groups, each permission given with a row scope; revocations, one permission taken from one person with the reason why.
- Row scopes - declared in code on a
ScopedQuerySet, or built from rules in the admin site;visible_to(user, action)reads them in a fixed number of queries. - Protected fields - read with one permission and written with another, in code or in the admin site, left out of every serializer, form, template, admin page and rule.
- Actions - status actions that alone write their values; actions found
in views, viewsets and admin actions, their permissions made on
migrate. - Policies -
BasePolicyfor the rules no permission can say, andModelPolicyfor every other model. - Django views and DRF -
PolicyRequiredMixin,ScopedQuerySetMixin,ProtectedFieldsViewMixin,policy_required;ModelPolicyPermission,PolicyPermission,ScopedModelViewSet, writes refused field by field. - The admin site - every
ModelAdminasking the policies, and the access pages: overview, bundles as a grid, people, permissions, the rule builder, protected fields, actions; right to left in Persian. - The safety rules - nobody gives themselves more, gives what they do not hold, or leaves nobody able to manage access.
- Access as code - the
ACCESSfile andclearance_sync, with--dry-run,--checkandSYNC_ON_MIGRATE. clearance.roles- roles with starting bundles and a ceiling.clearance.translations- names of permissions, bundles and roles in every language.- Checking - system checks at start-up, and
clearance_checkfor what only the database can tell. - English and Persian throughout; Python 3.11 to 3.14, Django 5.2, 6.0 and 6.1, DRF 3.16 and later.