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.

  1. Rootis_superuser. Bypasses everything.
  2. Executive — any title in Executives. Manager-tier everywhere, root nowhere.
  3. Department manager — an is_manager_role title in one specific department. Authority there and nowhere else.
  4. 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 edits
  • user.groups — flat Django Group membership

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