karni-approvals
Second-person (maker-checker) approval for high-value changes in a Karni project. A model opts in; from then on a guarded change by anyone — through the API, the admin or plain code — becomes a pending change holding the diff instead of a write. A checker holding the approve permission applies or rejects it, with a reason. The maker can never be the checker, in code.
Install
pip install karni-approvals
karni add approvals
karni migrate
karni add approvals adds the dependency, wires karni_approvals into the
# karni:apps block and records this module's agent guidance. No URL
include is needed: the API rides the auto-router. Keep the Celery worker
running — notifications and the hourly timeout sweep are tasks.
Opt a model in
from datetime import timedelta
from karni.models import BaseModel
class Invoice(BaseModel):
...
class Karni:
requires_approval = ["amount", "status"] # or True: any change, create and delete too
approvals_required = 2 # default KARNI_APPROVALS_REQUIRED (1)
approver_roles = ["finance"] # default KARNI_APPROVALS_APPROVER_ROLES ([])
approval_timeout = timedelta(hours=48) # or hours as a number; default the setting (never)
approval_on_timeout = "escalate" # or "expire" (the default)
approval_escalation_roles = ["cfo"] # default KARNI_APPROVALS_ESCALATION_ROLES
The model must be a karni.models.BaseModel (every save() and delete()
sends karni.signals.pre_write; that is the hook). karni check fails the
build for a guarded model that is not, for a malformed policy, and for
approver roles KARNI_ROLES does not declare (APR007).
What happens
- A guarded write is held. When a signed-in user changes a guarded
field (a list) or anything at all (
True), aPendingChangerecords the whole change as{field: [old, new]}and the write is stopped before any SQL: the API answers 202 with the pending change, the admin shows "sent for approval" and returns to the list, plain code getskarni.exceptions.KarniWriteDeferred(codeAPR001). The row is not touched; the change is itself history (karni.audit). - Writes pass through with no current user (migrations, seeds,
management commands), inside the module's own apply, and inside
karni_approvals.interception.bypass(reason="...")for trusted code. Bulk SQL (QuerySet.update()) is never seen —karni check'saudited-bulk-writerule catches it on audited models. - Deciding is
karni_approvals.services.decide(pending, checker, "approve" | "reject", reason), behind the API (POST /api/pending-changes/{id}/approve/andreject/) and the admin queue. Every rule lives there, so it holds wherever a decision comes from:- the maker can never be the checker (
APR002), superuser or not; - a checker needs the
karni_approvals.pendingchange:approve(or:reject) grant and, when the model declaresapprover_roles, one of those roles (APR003); - a checker sees and decides only pending changes whose row lies inside
their own scope — a branch checker, their branch (
APR003); a create has no row yet and is visible to whoever may view every row of that model; - one decision per person (
APR004); a decided change is final (APR005); - the change is applied as the last approver once
approvals_requiredapprovals are in — the row's own history then names the checker — and refused as stale when any recorded old value no longer matches (APR006, statusfailed).
- the maker can never be the checker (
- Timeouts. A change past
approval_timeoutis swept hourly:expiremarks it expired;escalateemails the escalation roles and extends it by the same timeout. - Notifications. On proposal the checkers are emailed from a Celery task
(
KARNI_APPROVALS_NOTIFY, a dotted path called with the pending change;""switches it off; replace it to post elsewhere). - Audit.
PendingChangeandDecisionare audited models: every proposal, approval and rejection is akarni.auditentry with its actor.
Where it shows
| Surface | What |
|---|---|
| Admin → Karni approvals → Pending changes | the queue in the checker's scope, each change's diff and decisions, Approve selected / Reject selected with a reason |
GET /api/pending-changes/ |
the queue (filters: status, action, content_type, maker), scoped; makers with an own grant see their own |
POST /api/pending-changes/{id}/approve/, …/reject/ |
{"reason": "..."} → the updated pending change; 403 APR002/APR003, 400 for the rest |
GET /api/pending-changes/count/ |
{"pending": n} the requester may decide — the badge |
@karni/approvals-ui |
<ApprovalsBadge/>, <ApprovalsQueue/>, usePendingCount, usePendingChanges, useDecidePendingChange |
Settings
Every setting arrives through the module's settings fragment
(karni_approvals.conf.DEFAULTS) and is overridable in the project's
settings.py or by its environment variable; each has a per-model override
in class Karni.
| Setting | Env var | Default | Meaning |
|---|---|---|---|
KARNI_APPROVALS_REQUIRED |
KARNI_APPROVALS_REQUIRED |
1 |
approvals a change needs |
KARNI_APPROVALS_APPROVER_ROLES |
KARNI_APPROVALS_APPROVER_ROLES |
[] |
roles that may approve, on top of the grant ([] = any holder) |
KARNI_APPROVALS_TIMEOUT_HOURS |
KARNI_APPROVALS_TIMEOUT_HOURS |
0 |
hours before a change times out (0 = never) |
KARNI_APPROVALS_ON_TIMEOUT |
KARNI_APPROVALS_ON_TIMEOUT |
expire |
expire or escalate |
KARNI_APPROVALS_ESCALATION_ROLES |
KARNI_APPROVALS_ESCALATION_ROLES |
[] |
roles emailed on escalation |
KARNI_APPROVALS_NOTIFY |
KARNI_APPROVALS_NOTIFY |
karni_approvals.notify.email_checkers |
the notifier ("" = off) |
KARNI_APPROVALS_FROM_EMAIL |
KARNI_APPROVALS_FROM_EMAIL |
"" |
sender; "" uses DEFAULT_FROM_EMAIL |
Escape hatches
requires_approvalper model, and every policy knob per model (class Karni) or project-wide (the settings above).karni_approvals.interception.bypass(reason=...)for trusted code; no current user (karni.middleware.current_user(None)) has the same effect.KARNI_APPROVALS_NOTIFYreplaces the notifier;notify.checkers_foris the reusable half.services.propose,decide,apply,visible_to,sweepare plain functions: call them from your own views, commands or tasks.- The API viewset and the admin class are ordinary Karni classes: subclass
PendingChangeViewSet(setauto_register = Falseon it) or register your own admin forPendingChange(explicit registrations win). - This module ships an explicit
admin.py(the queue);Decisionis not registered on its own (an inline of the queue).
Limits
Many-to-many changes are not part of a diff (they are written after
save() and never pass through pre_write); hold them behind a guarded
field or a service of your own. A change is held whole: an update touching
a guarded and an unguarded field waits as one.
Metadata
Release files for karni-approvals 0.3.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| karni_approvals-0.3.1.tar.gz | 26.7 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| karni_approvals-0.3.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 61.4 kB
Release files / karni_approvals-0.3.1.tar.gz
| Download URL | karni_approvals-0.3.1.tar.gz |
|---|---|
| Size | 26.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
29c089dfa7e3447d63aa30887b5e250908f1e27cc50a998b6f934f8fc04cfa60
|
|
BLAKE2b-256 checksum How to use checksums |
9c4ff6bfb38ab10b3a75d53d345befa82489d896898f06ec1e81910da92d0688
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Release files / karni_approvals-0.3.1-py3-none-any.whl
| Download URL | karni_approvals-0.3.1-py3-none-any.whl |
|---|---|
| Size | 34.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
31803d78dc2781526b91b5665f9d33c406b0eab8e003bbffff8eed898be40eac
|
|
BLAKE2b-256 checksum How to use checksums |
7b4a5a582a452fcaddb0e93332e52d73606cd97490f84603eb613f2a052fc614
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Security for karni-approvals 0.3.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.