description: karni.accounts: email-based users, the auth endpoints, one-time-token email flows, roles as data, two-factor and social login, mounted with one include.¶
karni.accounts¶
Email-based users, auth endpoints, one-time-token email flows, roles, 2FA and social login — mounted with one line:
Endpoints (all under the mount point): register/, login/, logout/,
refresh/, csrf/, me/, password/change/, password/reset/,
password/reset/confirm/, email/verify/, email/verify/resend/,
email/change/, email/change/confirm/, and 2fa/… when KARNI_2FA is on.
Swapping the user model¶
AUTH_USER_MODEL defaults to "karni_accounts.User" — a concrete, swappable
model. To add fields:
# myapp/models.py
from karni.accounts.models import AbstractKarniUser
class User(AbstractKarniUser):
display_name = models.CharField(max_length=120, blank=True)
# settings.py — BEFORE the first migrate of the project
AUTH_USER_MODEL = "myapp.User"
karni_settings(globals())
Swap before the first migrate; Django cannot re-point a user model
afterwards. Notes:
karni.accountsships real migrations;OneTimeToken's FK usesmigrations.swappable_dependency(settings.AUTH_USER_MODEL), so it follows your swapped model automatically.- Every Karni model (including
AbstractKarniUser) inheritskarni.models.BaseModel(UUID pk,created_at,updated_at). Changes toBaseModelfields in a future Karni release ripple migrations into every project's swapped user model — expect a generated migration on upgrade. - The user model declares
APIMeta(see docs/api.md) exposingid,email,first_name,last_name,email_verified,date_joined,created_at,updated_at, withemail,email_verifiedanddate_joinedread-only. RedeclareAPIMetaon your subclass to change it.
Auth modes¶
KARNI_AUTH_MODE (env or setting) is "jwt" (default) or "session".
- The URLconf is mode-independent and the mode is read per request, so
override_settings(KARNI_AUTH_MODE=...)works in tests. In session moderefresh/answers 405 with an explanatory detail. - Both authenticators (
JWTAuthentication,SessionAuthentication) are installed in every mode, andrest_framework_simplejwt.token_blacklistis always inINSTALLED_APPS— switching modes never adds or removes migrations.
JWT mode¶
login/ returns a SimpleJWT token pair plus the serialized user;
refresh/ rotates the refresh token (rotation + blacklist-after-rotation are
on); logout/ blacklists the posted refresh token. Lifetimes come from env:
KARNI_JWT_ACCESS_LIFETIME (minutes, default 15) and
KARNI_JWT_REFRESH_LIFETIME (days, default 7); override anything else by
defining SIMPLE_JWT yourself.
Password change and reset revoke refresh tokens. Both flows blacklist all of the user's outstanding refresh tokens. Access tokens already issued stay valid until they expire — with the 15-minute default that window is at most 15 minutes. This is by design: revoking access tokens would require a per-request blacklist lookup.
Session mode¶
login/ uses Django's session auth and sets the CSRF cookie
(ensure_csrf_cookie); SPAs that render before login can bootstrap CSRF via
GET …/csrf/ (204, sets the cookie). karni_settings() derives:
CSRF_TRUSTED_ORIGINS+= the origin ofKARNI_FRONTEND_URL,CORS_ALLOW_CREDENTIALS = True.
Password change keeps the user's own session alive
(update_session_auth_hash) while still revoking their refresh tokens.
Session mode is also what makes a plain link to an authenticated endpoint
work — a CSV export action, a file behind access — because the browser
sends the cookie with the navigation. In JWT mode a link cannot carry the
token; fetch the body through the KarniClient instead.
Cookie constraints: same-site setups (frontend and API on one origin, or the
Vite dev proxy) work out of the box. Cross-site setups (SPA on a different
registrable domain) additionally need SESSION_COOKIE_SAMESITE = "None" and
CSRF_COOKIE_SAMESITE = "None" plus HTTPS (Secure cookies) — assign them
after karni_settings(). Prefer a shared parent domain where possible.
Email flows¶
All account mail goes through one interface:
KARNI_ACCOUNTS_EMAIL_BACKEND(default"karni.accounts.emails.DjangoEmailBackend") — a dotted path to anAccountsEmailBackendsubclass.DjangoEmailBackendrenders Django templates and sends through yourEMAIL_BACKEND;karni.accounts.emails.InMemoryEmailBackendcollects into a class-leveloutboxfor tests.- Template override: the backend renders
karni_accounts/email/<template>_subject.txt,<template>_body.txtand optionally<template>_body.htmlforverify_email,reset_passwordandchange_email. Put files at the same paths in your project's template dirs to replace them — normal Django template resolution is the override mechanism. - Links: built from
KARNI_FRONTEND_URL(env, defaulthttp://localhost:5173) and theKARNI_ACCOUNTS_LINKSformat strings — override either to change where users land. - Token TTLs:
KARNI_ACCOUNTS_TOKEN_TTL(seconds), default{"verify_email": 86400, "reset_password": 3600, "change_email": 86400}. A new token for the same (user, purpose) invalidates prior unused ones.
Expired/blacklisted JWT bookkeeping is flushed daily by the
karni-flush-expired-tokens entry in CELERY_BEAT_SCHEDULE (task
karni.accounts.tasks.flush_expired_tokens). Remove or override that key in
your settings to opt out or reschedule.
Enumeration posture¶
Only password/reset/ is enumeration-silent (always 200, mail sent only when
the account exists). email/verify/resend/ requires authentication and may
respond honestly. register/ and email/change/ necessarily reveal address
existence through unique-email 400s — documented and accepted.
Bots and security events¶
register/ carries a honeypot: a request that fills the field named by
KARNI_ACCOUNTS_HONEYPOT_FIELD (default website) is answered 202
Accepted and creates nothing — a person never sees the field, so put it in
your registration form hidden from view (tabindex="-1", autocomplete="off",
visually hidden, never display: none alone, which some bots honour). Set
the setting to "" to turn it off.
Every auth outcome is a structlog event on the karni.accounts logger:
accounts.login.ok / .failed / .2fa_required, accounts.logout,
accounts.register.ok / .honeypot, accounts.email.verified,
accounts.password_reset.requested / .confirmed. Successes carry
user_id; failures carry email, a 16-character SHA-256 fingerprint of
the address, so logs correlate without holding addresses. Alert on a burst
of accounts.login.failed with one fingerprint (credential stuffing) or
many (password spraying).
Roles as data¶
A project declares its roles once, in KARNI_ROLES, and Karni keeps the
database in step. Membership stays a Django group (so the admin's group
widget, assign_role and every HasRole check keep working); the role adds
what a group cannot hold — a slug, a name, a description, and grants: what
members may do, on which rows.
# settings.py
KARNI_ROLES = {
"staff": {"name": "Staff", "system": True, "grants": {"*": "all"}},
"organizer": {
"name": "Organizer",
"grants": {"events.event:*": "own", "events.event:add": "all", "events.venue:view": "all"},
},
"venue_manager": {
"description": "Approves events at their venue.",
"grants": {"events.event:view": "related:venue", "events.event:approve": "related:venue"},
},
}
A grant maps a permission pattern to a scope. Permissions are
app_label.model:action — view, add, change, delete for every model,
plus the actions a model declares; * stands for the model or the action
("*" alone is everything). A later, more specific pattern wins over an
earlier wildcard. Scopes:
| scope | rows the action reaches |
|---|---|
all |
every row |
own |
rows whose Karni.owner_field is the user (created_by by default); the field may be a path, through foreign keys and many-to-manys |
related:<key> |
rows whose Karni.scope_paths[key] equals user.<key> (user.venue → event.venue) |
custom:<name> |
the Q a scope registered with karni.accounts.roles.register_scope(name, fn) returns |
The model side lives in class Karni, next to Meta and APIMeta:
class Event(BaseModel):
...
class Karni:
owner_field = "organizer" # the `own` scope (default: created_by)
scope_paths = {"venue": "venue"} # keys `related:` may use
actions = ["approve"] # permissions besides view/add/change/delete
field_roles = {"internal_note": ["staff"]} # who sees / changes a field
field_roles is field-level visibility (API reference):
a role can see a row without seeing every field of it, on the API, in
?expand=, in the schema and in the admin.
Membership: workspaces, teams, client accounts¶
"A member sees the rows of every workspace they belong to" is own with an
owner path that ends at the user through the membership many-to-many — no
custom scope, and assert_isolated drives it like any other grant:
class Workspace(BaseModel):
members = models.ManyToManyField(settings.AUTH_USER_MODEL)
class Karni:
owner_field = "members"
class Lead(BaseModel):
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE)
class Karni:
owner_field = "workspace__members"
class LeadActivity(BaseModel):
lead = models.ForeignKey(Lead, on_delete=models.CASCADE)
class Karni:
owner_field = "lead__workspace__members"
KARNI_ROLES = {
"staff": {"name": "Staff", "system": True, "grants": {"*": "all"}},
"member": {"grants": {"crm.*:*": "own"}},
}
Someone in two workspaces sees both; creating a row in a workspace you do not
belong to is refused (the relation only accepts workspaces you can read). A
model that joins the wildcard later without its own owner_field falls back
to created_by — give it the membership path too. Every path a grant filters
on is resolved at boot: one that does not exist on a model the grant covers
fails karni check with E214, never a 500 at request time.
The defaults are deliberately boring: a model with no Karni class is owned
by whoever created it, has no scope paths and the four standard actions. The
shipped KARNI_ROLES is staff (everything), user and customer (their own
rows); replace it entirely. A wildcard own grant simply skips models that
cannot be owned; an explicit one on such a model is an error (E206), as is
a pattern that matches nothing — a typo must not grant silently.
karni sync-roles (and every karni migrate, unless
KARNI_ROLES_AUTOSYNC = False) expands the declaration into Role,
Permission and RolePermission rows: it creates, updates and reports
roles the database holds that the declaration no longer names — it never
deletes a role. --dry-run shows the plan; --json is for scripts.
Asking the question:
user.can("events.event:approve")— the gate;user.can("approve", obj=event)also checks that the row is inside the grant's scope.user.has_role("organizer"),user.roles(theRolerows). Grants are resolved once per user instance, so a request pays one query however many checks it makes.access = Granted()on a viewset — the defaultkarni make viewsetwrites:Canfor the viewset's own model and the current action, so the roles decide who reaches which rows and the viewset names nothing; see Access control. The admin applies the same grants to its rows.Can("events.event:approve")in anaccessdeclaration: the gate and the row filter (the union of the scopes); see Access control. It must name the viewset's own model (E209); to gate on another model's permission useHasRole.@requires("events.event:approve")on a view method (the request's user) or a service function (the current user fromCurrentUserMiddleware); denial is a 403 with codeE208.
Nothing in Karni compares a role to a string: staff, user and customer
exist only as the default value of KARNI_ROLES.
Permission matrix¶
KARNI_ROLES is the baseline; the permission matrix is how it changes
after the project ships, with no deploy and no developer. In the admin it is
Roles → Permission matrix: roles across, models down (every model the
project has, minus the framework internals in KARNI_MATRIX_EXCLUDE), one
selector per action — view, add, change, delete and the model's own
Karni.actions — holding the scope that role reaches (all, own,
related:<key>, custom:<name>, or nothing). Bulk selectors set a whole
row or column. Nothing is saved until the confirmation page has shown the
diff.
What the matrix writes and what the declaration writes share one table, and
origin says which is which:
- a grant set in the matrix is
origin = "matrix":karni sync-roleskeeps it whatever the declaration says and lists it as overridden; - a declared grant revoked in the matrix stays as a row with scope
none, so the next sync does not quietly restore it; - a cell set to the value the declaration gives (the Declared (…) option)
is handed back:
origin = "declared", managed by the sync again.
Four guard rails, in karni.accounts.matrix.plan_changes, refuse a change
with the reason shown on the confirmation page (and as a 400 with code
E213 on the API); a refused batch writes nothing:
- A system role keeps its declared grants. Cells
KARNI_ROLESgives asystem: Truerole are locked; change the declaration. - Nobody grants what they do not hold. Touching
app.model:actionneeds the operator to hold that permission; granting a scope needs them to hold it at that scope (allcovers every scope). - Nobody removes their own last permission to change permissions
(
karni_accounts.rolepermission:change) — locking yourself out of production is a support ticket, not a feature. - The diff comes first. The admin confirms; the API has
dry_run.
Every change is history: Role and RolePermission are audited
(karni.audit), so the matrix page lists who changed which grant from what,
and the RolePermission admin's History tab has the rest. The Permission
and RolePermission admins are read-only — a grant row edited by hand would
skip the rails.
Effective permissions — Roles → Effective permissions, or the button on
a user's change page — is the read-only answer to "why can this person see
this?": every permission, the rows it reaches, and each role that grants it
with its scope and origin (a superuser holds everything; a revoked cell is
shown as revoked). Anyone may look at their own; someone else's needs
karni_accounts.rolepermission:view.
The same thing as an API, for a project's own UI and for fixtures and tests:
| route | does |
|---|---|
GET api/auth/permissions/matrix/ |
roles, models (each with its actions and the scopes it may take) and grants[role][permission] → {scope, origin, declared, locked} |
POST api/auth/permissions/matrix/ |
{"changes": [{"role", "permission", "scope"}], "dry_run": false} — scope is a value, null to revoke or "declared" to hand the cell back; the answer is the plan (changes, refusals), a refused request is a 400 E213 and nothing is written |
GET api/auth/permissions/effective/ |
the requester's own effective permissions |
GET api/auth/permissions/effective/<user id>/ |
anyone's (needs rolepermission:view) |
In code: build_matrix(), plan_changes(operator, changes),
apply_changes(operator, changes) (raises MatrixRefused, whose .plan
says why) and effective_permissions(user) in karni.accounts.matrix take
the operator explicitly, so a seed script or a test can drive them without
a request:
from karni.accounts.matrix import apply_changes
apply_changes(admin, [("support", "shop.order:view", "related:branch")])
Role helpers and object permissions¶
Membership helpers in karni.accounts.permissions: assign_role(user, slug),
remove_role(user, slug), user_roles(user), has_role(user, *slugs).
On a karni.api.BaseViewSet use the access declaration —
HasRole("staff") and Owned("user") there are backed by these helpers (role
names are looked up once per request); see
Access control. For plain DRF views:
RolePermission.for_roles("staff", "editor")→ a permission class.IsOwner— object permission comparingobj.<view.owner_field>(default"user",user_idfallback) torequest.user.ObjectPermissionsMixin—owner_field,object_permission_classesand afilter_queryset_for_user(qs)hook.BaseViewSetstill inherits it: the hook runs after the access read scope (overriding it needs# karni: allow(access-override): <reason>), andobject_permission_classeson aBaseViewSetis deprecated (removed in 1.0; it still runs after the access object check). (A deliberate staged exception to the three-occurrences rule.)
me/ includes roles: list[str] (read-only) so frontends can gate UI on
roles without a second request.
Rate limiting¶
Every auth endpoint is scope-throttled (DRF ScopedRateThrottle, backed by
the default cache — Redis in the Karni stack). Defaults:
| scope | rate | endpoints |
|---|---|---|
auth.login |
10/min |
login (per client IP) |
auth.login_account |
10/hour |
login + 2fa/login, keyed on the posted email |
auth.register |
10/hour |
register |
auth.password_reset |
5/hour |
reset request + confirm |
auth.token |
30/min |
refresh |
auth.verify |
10/hour |
verify + resend |
auth.email_change |
5/hour |
change + confirm |
auth.2fa |
10/min |
2FA endpoints |
Tune via KARNI_AUTH_THROTTLE_RATES — a partial dict (setting, or JSON env
var) merged over the defaults before landing in
REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]; set a rate to None to disable
that scope. The per-account login throttle keys on the normalized posted
email, so distributed attempts against one account are still limited.
Throttling resolves the client IP honouring NUM_PROXIES (env
KARNI_NUM_PROXIES, default 1 — Caddy fronts the stack).
Two-factor auth (TOTP)¶
Set KARNI_2FA=True. This installs django_otp +
django_otp.plugins.otp_totp (and, in session mode only, OTPMiddleware
after AuthenticationMiddleware). Endpoints: 2fa/setup/ (unconfirmed
device + otpauth_url), 2fa/confirm/, 2fa/disable/, 2fa/login/.
Login gate: when the account has a confirmed TOTP device, login/ returns
{"requires_2fa": true, "ephemeral_token": …} and no credentials. The
ephemeral token is signed, expires after 300 seconds and is single-use
(replays are rejected). POST 2fa/login/ {ephemeral_token, code} completes
the login (token pair in jwt mode, session login + django_otp.login in
session mode).
Social login (django-allauth)¶
Set KARNI_SOCIAL_AUTH=True and list providers, e.g.
KARNI_SOCIAL_PROVIDERS=google,github. karni_settings() wires the allauth
apps, provider apps, middleware, SITE_ID, authentication backends and the
email-only account settings.
v0.1 limitation: social auth is session-mode only. Only the provider /
socialaccount URLs are exposed (no parallel allauth HTML account surface),
and KARNI_SOCIAL_AUTH=True with KARNI_AUTH_MODE="jwt" fails startup
validation (an allauth-headless JWT strategy is future work). Verified social
signups mirror into User.email_verified — Karni's field stays the single
source of truth.
Admin¶
KarniUserAdmin (email-based UserAdmin with working add/change forms,
styled by karni.admin) is registered automatically while
AUTH_USER_MODEL == "karni_accounts.User". After swapping the user model,
reuse it:
# myapp/admin.py
from django.contrib import admin
from karni.accounts.admin import KarniUserAdmin
from myapp.models import User
@admin.register(User)
class UserAdmin(KarniUserAdmin):
pass
Overriding a single endpoint¶
URL resolution is first-match-wins — declare yours before the include:
urlpatterns = [
path("api/auth/register/", MyRegisterView.as_view()),
path("api/auth/", include("karni.accounts.urls")),
]
For behaviour changes, subclass the serializers/views and call your own service functions; the shipped views stay thin on purpose.