Most CRUD-heavy apps start with 'roles' that end up being hand-coded per endpoint. A year in, nobody can answer 'who can approve a return?' without reading the code.
The descent into permission chaos follows a predictable path. First, a simple if user.is_staff check on <a href='/security' class='text-[var(--color-accent)] underline'>the most sensitive endpoints</a>. Then a few custom permission classes. Then per-tenant overrides because 'our biggest customer has a different team structure.' Within a year, permissions are scattered across views, serializers, and frontend conditionals, and the only way to know what a given user can do is to log in as them and try things.
Four groups, one matrix Quotery ships four groups: admin, manager, commercial, warehouse. The <a href='/docs/product/admin/permissions-groups' class='text-[var(--color-accent)] underline'>permission matrix</a> is documented in CLAUDE.md β a single table showing who can CRUD what. Adding a new feature always starts with 'add the row to the matrix, then wire the check.'
The matrix is deliberately flat and deliberately small. Admin can do everything. Manager can do everything except tenant-level configuration changes. Commercial can create and manage quotes, see their own clients, and view stock availability (not adjust it). Warehouse can manage stock, post deliveries, process returns, and see fulfillment-related data. Four rows, maybe eight columns. If a permission doesn't fit in this matrix, the feature design is probably too complex.
Row-level visibility
Commercial users see their own quotes; admin and manager see tenant-wide. The service layer enforces this β the view just passes request.user through.
This is the subtle part. Group membership controls coarse-grained access (can this user close quotes at all?). Row-level scoping controls fine-grained access (which quotes can this user close?). Commercial users pass both checks: they have the 'can close quote' permission, and their query is scoped to quotes they own. A warehouse user fails the first check β they never reach the second. The two mechanisms are independent but compose cleanly.
Why not fine-grained ACLs We chose group-based because the cost of fine-grained ACLs is paid on every request forever. A team of 20 does not have 20 roles; it has 4. The hierarchy is flat by design.
Fine-grained ACLs (user-level permissions on individual resources) are the correct answer for some domains β document sharing platforms, project management tools, anything where ad-hoc sharing is a core feature. Distribution operations are not that domain. The workflows are structured, the roles are stable, and the permission boundaries align with organizational boundaries. A group-based model maps cleanly to the actual business: salespeople do sales things, warehouse workers do warehouse things, managers oversee both, admins configure the system. Adding per-user ACLs to this model would add complexity without adding capability.
