RBAC
All authorization in Pengin-Pi-3 lives in main/auth/. It is the one place
permission, group, and user-role logic belongs — nothing outside it should
implement its own check. A feature that needs authorization extends or calls
into this package rather than keeping a private copy.
That rule exists because permission logic that gets copied gets forgotten. When the rules change, a private copy keeps enforcing the old ones, and the bug it produces is a security bug rather than a visible failure.
The model
Three objects, built on Django's own Group:
| Concept | Implementation | Example |
|---|---|---|
| Department | django.contrib.auth.models.Group | Sales, Engineering, Executives |
| Title | TeamRole | Employee, Manager — scoped to one department |
| Assignment | TeamUserRole | The user-to-title join |
A TeamRole belongs to exactly one department and is unique on
(group, name). "Manager" in Sales and "Manager" in Engineering are
different rows with different ids. A title carries an is_manager_role
flag marking it manager-tier, which grants department-wide authority to
anyone holding it.
TeamUserRole binds a user to a title, unique on (user, role), stamped
with an assignment date. A user can hold titles in several departments at
once, and their authority in each is independent.
Both models carry edit history through HistoryMixin.
Two things that look like roles but aren't
Administrator is not a stored role. It means User.is_superuser — real
Django root. It is a display label, not a dropdown option.
display_title_for_user() returns "Administrator" for a superuser no matter
what titles they actually hold, and is_root() short-circuits every other
check in the module.
Executives is a department, not root. Holding any title in the
department named Executives grants manager-tier authority across every
other department — but never superuser. It is a cross-department evaluator
tier. The name is a constant, EXECUTIVES_DEPARTMENT_NAME, so renaming the
department means changing it there.
Four tiers of authority
Understanding which tier a check belongs to is most of using this correctly.
- Root —
is_superuser. Bypasses everything. - Executive — any title in Executives. Manager-tier everywhere, root nowhere.
- Department manager — an
is_manager_roletitle in one specific department. Authority there and nowhere else. - Department member — any title in a department. Access, not authority.
is_staff is deliberately not one of these. It means "internal person,"
not "may act on any department." The distinction matters: a plain staff
member must not be able to edit another department's calendar just for being
staff.
validated is a separate axis again — it means the email address is
confirmed. A user can be staff and unvalidated, and several guards check both.
The permission surface
Import from main.auth (or main.auth.permissions). These are the
functions; there is no other sanctioned way to ask.
Tier checks
is_root(user)
is_executive_manager(user) # root, or any Executives title
is_manager_of_group(user, group) # root, executive, or manager here
can_access_group(user, group) # manager-tier OR plain member; staff only
can_access_group accepts a Group instance or a group id, which saves a
lookup when all you have is a foreign key.
Queryset helpers
get_managed_groups(user) # departments they manage
get_all_groups_for_user_with_extended_rbac(user) # departments they're in at all
get_users_with_extended_rbac_to_group(department) # validated staff eligible for it
All three return querysets, including empty ones for anonymous users — never
None. Root and Executives get Group.objects.all() from the first two, so
"manages everything" needs no special-casing at the call site.
Display
display_title_for_user(user, department=None)
Returns "Administrator" for root, their title in department if given, their
first title anywhere otherwise, and "Employee" as a final fallback.
View guards
Class-based (main/auth/mixins.py):
from main.auth import StaffRequiredMixin, LoginAndValidationRequiredMixin
class MyStaffView(StaffRequiredMixin, View):
...
class MyMemberView(LoginAndValidationRequiredMixin, View):
...
StaffRequiredMixin requires authentication and is_staff.
LoginAndValidationRequiredMixin requires authentication and a validated
account — use it wherever an unverified email shouldn't be able to act.
Function-based (main/auth/decorators.py): group_required(name),
is_admin_required, plus two that inject context rather than gate —
is_admin_provider passes is_admin= into the view, user_group_provider
passes groups=.
The requires_auth marker
The decorators set wrapped_view.requires_auth = True. This is not
bookkeeping — main/sitemaps.py reads it to decide whether a route is
public.
MRO introspection can see LoginRequiredMixin in a class-based view's
ancestry, but it cannot see a guard applied via
@method_decorator(group_required(...), name='dispatch'). Without the
marker, those gated pages would land in sitemap.xml and get advertised to
crawlers.
If you write a new guard decorator, set the marker. A login-gated page leaking into the sitemap is the failure mode, and nothing will tell you.
Derived state: the part that bites
This is the most important thing on the page.
A user's department membership exists in two places:
TeamUserRole— the real model, what the staff console editsuser.groups— flat DjangoGroupmembership
They are not automatically the same. TeamUserRole is the source of truth,
but plenty of code (Django admin, third-party packages, anything ported from
Pengin-Pi-2, and the Event visibility checks) reads user.groups directly.
Assigning a TeamUserRole without updating user.groups produces a user
whose permissions depend on which system happens to be asking.
main/auth/sync.py is the bridge. Every surface that assigns or unassigns
a team role must call it.
from main.auth import sync_team_role_groups, cascade_is_staff
previous_role_ids = set(
TeamUserRole.objects.filter(user=user).values_list('role_id', flat=True)
)
role_formset.save()
current_role_ids = set(
TeamUserRole.objects.filter(user=user).values_list('role_id', flat=True)
)
sync_team_role_groups(user, previous_role_ids, current_role_ids)
cascade_is_staff(user)
Capture the before-set before the save and the after-set after it.
Additive, never blindly strip
Both sync functions follow the same policy, and it's worth knowing why.
sync_team_role_groups() adds the Group for any newly assigned role. On
removal, it drops the group membership only if no other current role of
theirs still targets that department. Someone holding both "Manager" in
Sales and "Employee" in Sales doesn't lose Sales membership when one is
removed.
cascade_is_staff() flips is_staff on when a user holds any team role —
holding a title means you're internal by definition. It is one-directional.
Removing every role later does not un-staff someone. is_staff stays a
normal, independently editable field, so an automated cascade can't silently
revoke access an administrator set deliberately.
Reference implementation
`main/
Pages Here
No sub-pages yet.
Page Info
Wiki: Docs
Created on Sep 20, 2026 by Tobu Pengin, L.L.C.
Maintainers
| Editor | Last Activity |
|---|---|
| Tobu Pengin, L.L.C. creator | Sep 20, 2026 |