Skip to main content

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), a PendingChange records 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 gets karni.exceptions.KarniWriteDeferred (code APR001). 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's audited-bulk-write rule 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/ and reject/) 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 declares approver_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_required approvals are in — the row's own history then names the checker — and refused as stale when any recorded old value no longer matches (APR006, status failed).
  • Timeouts. A change past approval_timeout is swept hourly: expire marks it expired; escalate emails 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. PendingChange and Decision are audited models: every proposal, approval and rejection is a karni.audit entry 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_approval per 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_NOTIFY replaces the notifier; notify.checkers_for is the reusable half.
  • services.propose, decide, apply, visible_to, sweep are plain functions: call them from your own views, commands or tasks.
  • The API viewset and the admin class are ordinary Karni classes: subclass PendingChangeViewSet (set auto_register = False on it) or register your own admin for PendingChange (explicit registrations win).
  • This module ships an explicit admin.py (the queue); Decision is 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)

Source distribution for karni-approvals 0.3.1
File Size Uploaded Provenance
karni_approvals-0.3.1.tar.gz 26.7 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for karni-approvals 0.3.1
File Interpreter ABI Platform
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.

Release history Release notifications | RSS feed

This release

0.3.1 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page