description: ADR 0002: the audit of Karni's permission and admin surface that precedes the roles, row scoping, field visibility, history, matrix and import/export work.¶
ADR 0002 — Permissions and admin: audit before the retrofits¶
Status: accepted 2026-10-18 (P0 of the Permissions & Admin sequence). No code
changed in this session. Since then: P1 (roles as data), P2 (row-level
scoping — Granted(), the admin's row scope, scope_exempt, the grant
isolation dimension), P3 (field-level visibility — Karni.field_roles,
per-role serializer, schema and admin, the ordering/filter/search oracles
closed), P4 (change history — karni.audit, Karni.audit, the History
tab, /history/, <ChangeHistory/>, audited-bulk-write), P5 (the
permission matrix — the admin page, its guard rails, effective permissions,
api/auth/permissions/, RolePermission.origin), P6 (import and export
— karni.transfer, /export/, the four-step import with its mandatory dry
run, audit batches, <ResourceImport/>), M1 (the karni-approvals
module on the new karni.signals.pre_write hook) and M2 (the
karni-impersonation module on the new karni.vetoes registry) have
shipped — the sequence is complete; §2 below describes the state before
them.
This audits Karni's permission surface before the six retrofits (P1 roles as data, P2 row-level scoping, P3 field-level visibility, P4 change history, P5 the permission matrix, P6 import/export) and the two modules (approvals, impersonation). Each gap is ranked by blast radius — what leaks or forks when it is missing — and priced in the lines of project code a typical app writes today because Karni does not provide it.
1. Where role names live¶
Every occurrence of staff, user, customer (and the examples' organizer,
editor, admin) as a string literal in packages/karni was checked:
| Where | What it is | Project can replace without editing karni? |
|---|---|---|
karni/api/access.py (docstrings, error text) |
examples: HasRole("staff"), Owned("organizer") |
yes — prose only |
karni/errorcodes/__init__.py E103/E105/E109 fix text |
examples | yes — prose only |
karni/testing/api.py, isolation.py, pytest_plugin.py |
examples in docstrings | yes |
karni/accounts/permissions.py:95 |
RolePermission.for_roles("editor", "admin") in a docstring |
yes |
karni/accounts/permissions.py:135,169 |
owner_field = "user" — a field name default on the legacy IsOwner / ObjectPermissionsMixin, not a role |
yes (class attribute) |
karni/introspect.py:21, karni/admin/__init__.py:64 |
"admin" as an app label to skip |
n/a |
Finding: no code path in karni compares a role to a literal. Roles are
Django groups; HasRole("x") and assign_role(user, "x") create and read
groups by name, so a project with a role called warehouse_manager needs no
karni edit today. What breaks instead is everything around the name:
- there is no declared role set — a role exists only because some
accessdeclaration orassign_rolecall mentions it, sokarni contextcannot list roles, a typo creates a silent new role, and nothing says which roles are system roles; karni seedderives the roles to grant the demo login by scanningaccessdeclarations forHasRolenames (seeding._Run.roles()), which is the only "role registry" that exists, and it is indirect;- a role's reach (which rows, which fields) is written in code per viewset
(
Owned("organizer") | HasRole("staff")), so granting a role more or fewer rows is a code change and a deploy.
So the fork risk P1 names is real, but it is not in karni.accounts; it is in
every project's viewsets.py, where role policy lives as code.
2. What enforcement exists today¶
| Capability | Status | Code |
|---|---|---|
| Role check on an endpoint | present | karni.api.access.HasRole in the access declaration: the request gate (AccessPermission.has_permission) and the row filter (rows() → all rows for holders). Legacy karni.accounts.permissions.RolePermission for plain DRF views. |
| Row-level scoping | present, static | Owned(path) (direct or traversal, to-many with distinct()), Where(fn), |/&, compiled into BaseViewSet.get_queryset(); out-of-scope detail → 404 (ADR 2.4); relation-field PKs and ?expand= go through the target viewset's read scope (2.5); owner stamping on create. Absent: a per-role scope (all / own / related / custom) chosen as data; the same scope applied in the admin; any export (there is none to scope). The admin (karni.admin.soft_delete.SoftDeleteAdminMixin.get_queryset) lifts only soft_delete; a staff user with admin access sees every row of every model. |
| Field-level masking | absent | APIMeta.read_only / write_only / exclude apply to everyone. A role that must not see salary needs a second serializer (karni make serializer --reason) and a get_serializer_class() branch per role, in every viewset, and the admin, filters and ordering still expose the field. |
| Field-level change history | absent | AuditedModel records who (created_by, updated_by) and when; nothing records what changed. No history endpoint, admin tab or component. |
| A UI for changing who can do what, no deploy | absent | Django's admin still shows Group permissions, but access ignores Django model permissions, so editing them changes nothing — a misleading screen. |
| Generic import/export for master data | absent | EventHub wrote its attendee CSV export by hand (examples/eventhub/FRICTION.md F3); .learning records doc_ACA's hand-rolled export that splats client-supplied column names into .values(). No importer exists anywhere. |
3. What ADR 0001 chose, and whether the code matches¶
ADR 0001 Decision 2 (rulings 2.1–2.8) chose layered, queryset-authoritative
enforcement compiled from one mandatory access declaration — gate and row
filter from the same predicate, 404 out of scope, scoped relation fields and
expansion, auto-wrapped get_queryset, a closed vocabulary of Owned,
HasRole, Authenticated, Public, Where, Unscoped.
The code matches it. karni/api/access.py compiles both layers;
viewset-access fails the build on a missing declaration; assert_crud and
assert_isolated prove the 404/403 split and the isolation for every
resource; EventHub's four viewsets are declared this way.
Ruling 2.2 is the hinge for this sequence: "HasRole(...) over Django
Groups now. The Can("area:action") slot is reserved for a later DB-grants
tier; declarations written against HasRole keep working." P1 and P2 are
that tier. They do not replace the access declaration — they give it a
predicate whose answer is data.
4. What my own past projects did¶
.learning/PATTERNS.md (the extraction across ~20 projects):
- Pattern 6, weighted 14, ten projects: role enforcement diverged from
hard-coded
IsXpermission classes on aroleCharField (five projects) to bhukailasa's DB-backedarea:actiongrants with wildcards and a singlerequire()factory every route must take — recommended there as the core default. Karni today sits between: declarations in code, roles as groups, no grants table. P1'suser.can(action)and@requires(action)is bhukailasa's factory; P2's scopes are what that project lacked. - Pattern 37, three projects: tenant/branch scoping done once well
(xperso's
TenantScopedMixin: a single enforcement point, stamps the owner, never trusts the body, isolation tests). Karni'sOwned+assert_isolatedmatches the shape; the admin was not covered there either. - Pattern 19, weighted 8, five projects: audit logging diverged (a library, thread-local + signals, explicit before/after JSON with IP, append-only). Weakness 13: three money-touching apps had no audit trail at all. P4 is this pattern as a default-off opt-in with redaction.
- Pattern 34, eleven projects: three independent "config-driven admin kits" and copy-paste admin screens — the permission matrix (P5) and import/export (P6) are the two screens those kits rebuilt most.
- Weakness 9: client-side permission walls that fail open (doc_ACA). Karni's doctrine already puts the wall on the queryset; P3 extends it to fields so the client never has to hide anything.
- Weakness 19: hand-parsed export endpoints. P6 replaces them.
Where the corpus and Karni's current design diverge: the corpus's best project put policy in the database; Karni put it in a declaration. The retrofits keep the declaration (it is what an agent reads and the build checks) and let its predicate read the database.
5. What the gaps cost, in project lines¶
Estimates per typical app (10 models, 3 roles), from EventHub and the corpus:
| Gap | Lines a project writes today | Why |
|---|---|---|
| P2 row scoping as data, in the admin and exports | ~150 | a get_queryset or Where per viewset the role policy touches (~8 lines × N), the same filter copied into every admin class and export; and the one copy an agent forgets is a leak, not a bug |
| P3 field-level visibility | ~450 | one serializer per role per model (3 × 10 × ~15 lines) plus get_serializer_class() branches, plus the admin/filter/ordering holes nobody closes |
| P4 change history | ~250–350 | a history model, pre_save snapshot + signals, a diff helper, redaction, an admin tab and an API endpoint |
| P1 roles as data | ~120 | role constants, a seed of groups, helpers, and karni context blindness; the real cost is the fork when a role needs a scope the code did not anticipate |
| P6 import/export | ~200 per import, ~30 per export | parse, validate, preview, commit, template — rebuilt per model per project (FRICTION F3 is the export half) |
| P5 permission matrix | 0 lines, 1 deploy per change | every "give Priya access to invoices" is a code change today |
6. Ranked by blast radius, with the files each gap changes¶
- P2 — scoping as data, applied everywhere (data leak when missing).
karni/api/access.py(Canbecomes real: gate + rows from grants),karni/api/viewsets.py(Granted()default path),karni/admin/base.pyandsoft_delete.py(scope inget_queryset),karni/testing/isolation.pyandapi.py(the matrix tests),karni/agent/check.py+rules.py(scope_exemptreasons listed),karni-cligenerators (--access granted). - P3 — field visibility (schema leak when missing).
karni/api/serializers.py(per-request field set),karni/api/filters.pyand ordering (hidden fields are not filterable or orderable),karni/api/schema.py(per-role schema),karni/admin/derive.py,@karni/uiresource components (defensive filtering), P6's exporters. - P1 — roles as data (fork risk; prerequisite of 2, 3, 5).
karni/accounts/models.py(+ migration),permissions.py,karni/conf/base.py(KARNI_ROLES), akarni_sync_rolescommand andkarni sync-rolesrunner,karni/introspect.py(ROLES:line),karni/seeding.py(demo roles from the declared set),docs/accounts.md. - P4 — change history (compliance gap). New
karni/audit/, hooks inkarni/models.py(opt-in metadata),BaseViewSethistory action, admin tab,@karni/uiChangeHistory, a beat task,karni checkrule forupdate()on audited models. - P6 — import/export (leak if built before 2 and 3). New
karni/api/exports.pyandimports.py,BaseViewSetactions, admin actions,@karni/uiResourceImportand the table's export button, Celery task for large files. - P5 — permission matrix (operational cost, needs 1 and 2). New admin views on django-unfold, an API, guard rails, audit of the matrix itself.
7. Design settled here, so the retrofits agree with each other¶
These are the decisions P1–P6 implement; they keep ADR 0001's rulings intact.
- The
accessdeclaration stays the single enforcement path. The grants tier is a predicate, not a bypass:Can("events:view")answers the gate (does any of the user's roles hold a grant for that action?) and the rows (the union of the grants' scopes).Granted()isCanfor the viewset's own model and the current action, soaccess = Granted()means "whatever the roles say", with scoping included — the default path P2 asks for, still an explicit declaration (ruling 2.1), still compiled into both layers (2.3), still 404 out of scope (2.4). - Roles stay Django groups.
Roleis one-to-one withGroup(slug, name, description,is_system);user.rolesreads throughuser.groups.HasRole,assign_role, the admin, third-party apps and every existing declaration keep working; nothing is migrated away from groups. - Permissions are Karni's, not
auth.Permission.Permissionis(content_type, action)where actions areview/add/change/deleteplus the custom actions a model declares;RolePermissionis the join and carries the scope (all,own,related:<path>,custom:<name>). Django's model permissions are left to the admin's own machinery and documented as unrelated toaccess. - A project declares roles in one place: the
KARNI_ROLESsetting (the shipped default isstaff/user/customer), reconciled bykarni sync-roles, which creates and updates and reports but never deletes. Nothing in karni compares a role to a literal; the defaults are data inkarni.conf.base. - Model metadata lives in
class Karni:owner_field(for theownscope;created_bywhen the model is audited and nothing is declared — a role-level scope declared in data, unlikeOwned()in code, which keeps ruling 2.6),scope_paths,actions, and laterfield_roles,audit,requires_approval. - Per-request caching is mandatory: the user's roles and grants are resolved once per request (the request-scoped store the current-user middleware already keeps) and never per object.
- Boring defaults: a new model with no
Karnimetadata andaccess = Granted()is fully visible tostaff, scoped to its owner foruser, and audited not at all. Every deviation is declared. - Tests are the deliverable:
assert_isolatedgrows the scope dimensions (own,related);assert_crudgains field-visibility checks;karni.testingships the "cannot reach another scope through list, retrieve, update, delete, expand, filter, search, ordering, admin, export" matrix as one call.
8. Order¶
P1 → P2 (stop and try to break it) → P3 → P4 (independent; may interleave) → P5 → P6 → modules M1, M2. Each is its own session, its own migration, and a full run of the existing suites before the next.