karni 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- karni/__init__.py +23 -0
- karni/accounts/__init__.py +10 -0
- karni/accounts/admin.py +381 -0
- karni/accounts/api/__init__.py +15 -0
- karni/accounts/api/matrix.py +132 -0
- karni/accounts/api/serializers.py +221 -0
- karni/accounts/api/throttles.py +116 -0
- karni/accounts/api/views.py +512 -0
- karni/accounts/apps.py +61 -0
- karni/accounts/emails.py +217 -0
- karni/accounts/managers.py +80 -0
- karni/accounts/matrix.py +772 -0
- karni/accounts/matrix_admin.py +185 -0
- karni/accounts/migrations/0001_initial.py +77 -0
- karni/accounts/migrations/0002_roles.py +109 -0
- karni/accounts/migrations/0003_matrix.py +30 -0
- karni/accounts/migrations/0004_pg_trgm.py +42 -0
- karni/accounts/migrations/__init__.py +0 -0
- karni/accounts/models.py +290 -0
- karni/accounts/permissions.py +214 -0
- karni/accounts/roles.py +570 -0
- karni/accounts/services.py +276 -0
- karni/accounts/tasks.py +48 -0
- karni/accounts/templates/karni_accounts/effective_permissions.html +81 -0
- karni/accounts/templates/karni_accounts/email/change_email_body.html +24 -0
- karni/accounts/templates/karni_accounts/email/change_email_body.txt +11 -0
- karni/accounts/templates/karni_accounts/email/change_email_subject.txt +1 -0
- karni/accounts/templates/karni_accounts/email/reset_password_body.html +24 -0
- karni/accounts/templates/karni_accounts/email/reset_password_body.txt +11 -0
- karni/accounts/templates/karni_accounts/email/reset_password_subject.txt +1 -0
- karni/accounts/templates/karni_accounts/email/verify_email_body.html +22 -0
- karni/accounts/templates/karni_accounts/email/verify_email_body.txt +9 -0
- karni/accounts/templates/karni_accounts/email/verify_email_subject.txt +1 -0
- karni/accounts/templates/karni_accounts/permission_matrix.html +169 -0
- karni/accounts/templates/karni_accounts/permission_matrix_confirm.html +73 -0
- karni/accounts/tokens.py +157 -0
- karni/accounts/twofa.py +461 -0
- karni/accounts/urls.py +123 -0
- karni/accounts/visibility.py +75 -0
- karni/admin/__init__.py +185 -0
- karni/admin/apps.py +40 -0
- karni/admin/base.py +623 -0
- karni/admin/derive.py +223 -0
- karni/admin/inlines.py +121 -0
- karni/admin/scope.py +73 -0
- karni/admin/soft_delete.py +357 -0
- karni/admin/static/karni/brand/karni-mark-light.svg +5 -0
- karni/admin/static/karni/brand/karni-mark-simple.svg +8 -0
- karni/admin/static/karni/brand/karni-mark.svg +5 -0
- karni/admin/templates/karni_admin/hard_delete_selected_confirmation.html +59 -0
- karni/admin/templates/karni_admin/import.html +150 -0
- karni/admin/templates/karni_admin/object_history.html +40 -0
- karni/admin/transfer.py +293 -0
- karni/agent/__init__.py +82 -0
- karni/agent/__main__.py +44 -0
- karni/agent/check.py +658 -0
- karni/agent/history.py +328 -0
- karni/agent/history_chronicle.py +246 -0
- karni/agent/overrides.py +194 -0
- karni/agent/rules.py +673 -0
- karni/api/__init__.py +97 -0
- karni/api/access.py +1197 -0
- karni/api/caching.py +333 -0
- karni/api/expansion.py +349 -0
- karni/api/filters.py +335 -0
- karni/api/optimizer.py +423 -0
- karni/api/routers.py +302 -0
- karni/api/schema.py +378 -0
- karni/api/search/__init__.py +104 -0
- karni/api/search/postgres.py +159 -0
- karni/api/search/simple.py +54 -0
- karni/api/serializers.py +523 -0
- karni/api/urls.py +35 -0
- karni/api/viewsets.py +962 -0
- karni/apps.py +49 -0
- karni/audit/__init__.py +41 -0
- karni/audit/admin.py +32 -0
- karni/audit/apps.py +17 -0
- karni/audit/capture.py +335 -0
- karni/audit/migrations/0001_initial.py +76 -0
- karni/audit/migrations/0002_batches.py +62 -0
- karni/audit/migrations/__init__.py +0 -0
- karni/audit/models.py +115 -0
- karni/audit/schedules.py +10 -0
- karni/audit/serializers.py +45 -0
- karni/audit/services.py +28 -0
- karni/audit/tasks.py +38 -0
- karni/celery.py +226 -0
- karni/changelog/0.2.0.toml +304 -0
- karni/changelog/0.3.0.toml +25 -0
- karni/changelog/__init__.py +248 -0
- karni/checks.py +466 -0
- karni/conf/__init__.py +442 -0
- karni/conf/admin_logo.py +54 -0
- karni/conf/base.py +514 -0
- karni/conf/dev.py +36 -0
- karni/conf/logging.py +95 -0
- karni/conf/prod.py +46 -0
- karni/conf/reference.py +200 -0
- karni/conf/sentry.py +52 -0
- karni/conf/validation.py +466 -0
- karni/deprecation.py +77 -0
- karni/dotenv.py +48 -0
- karni/errorcodes/__init__.py +1171 -0
- karni/errorcodes/__main__.py +34 -0
- karni/events.py +418 -0
- karni/exceptions.py +133 -0
- karni/health/__init__.py +42 -0
- karni/health/checks.py +83 -0
- karni/health/urls.py +23 -0
- karni/health/views.py +95 -0
- karni/imports.py +34 -0
- karni/introspect.py +313 -0
- karni/links.py +217 -0
- karni/management/__init__.py +0 -0
- karni/management/commands/__init__.py +0 -0
- karni/management/commands/karni_check.py +56 -0
- karni/management/commands/karni_context.py +22 -0
- karni/management/commands/karni_routes.py +165 -0
- karni/management/commands/karni_schedules.py +81 -0
- karni/management/commands/karni_seed.py +76 -0
- karni/management/commands/karni_sync_roles.py +38 -0
- karni/managers.py +278 -0
- karni/meta.py +334 -0
- karni/middleware.py +159 -0
- karni/models.py +251 -0
- karni/pagination.py +229 -0
- karni/performance.py +404 -0
- karni/rows.py +78 -0
- karni/schedule.py +547 -0
- karni/scopes.py +413 -0
- karni/scoping.py +63 -0
- karni/seeding.py +734 -0
- karni/signals.py +30 -0
- karni/storage/__init__.py +78 -0
- karni/storage/s3.py +144 -0
- karni/storage/thumbnails.py +171 -0
- karni/testing/__init__.py +73 -0
- karni/testing/_source.py +202 -0
- karni/testing/api.py +1148 -0
- karni/testing/architecture.py +525 -0
- karni/testing/duplication.py +133 -0
- karni/testing/factories.py +578 -0
- karni/testing/isolation.py +718 -0
- karni/testing/performance.py +414 -0
- karni/testing/pytest_plugin.py +161 -0
- karni/testing/schema.py +402 -0
- karni/testing/values.py +728 -0
- karni/transfer/__init__.py +36 -0
- karni/transfer/apps.py +14 -0
- karni/transfer/exports.py +172 -0
- karni/transfer/fields.py +186 -0
- karni/transfer/formats.py +200 -0
- karni/transfer/imports.py +528 -0
- karni/transfer/migrations/0001_initial.py +165 -0
- karni/transfer/migrations/__init__.py +0 -0
- karni/transfer/models.py +130 -0
- karni/transfer/serializers.py +72 -0
- karni/transfer/services.py +232 -0
- karni/transfer/tasks.py +22 -0
- karni/transfer/template.py +51 -0
- karni/vetoes.py +90 -0
- karni/welcome/__init__.py +63 -0
- karni/welcome/inventory.py +213 -0
- karni/welcome/page.py +269 -0
- karni/welcome/status.py +171 -0
- karni/welcome/views.py +29 -0
- karni-0.3.1.dist-info/METADATA +202 -0
- karni-0.3.1.dist-info/RECORD +172 -0
- karni-0.3.1.dist-info/WHEEL +4 -0
- karni-0.3.1.dist-info/entry_points.txt +9 -0
- karni-0.3.1.dist-info/licenses/LICENSE +201 -0
karni/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Karni — batteries-included Django framework glue.
|
|
2
|
+
|
|
3
|
+
Karni packages the settings, Celery, storage and health-check boilerplate that
|
|
4
|
+
every Django + DRF + Celery project repeats, behind a handful of small,
|
|
5
|
+
overridable entry points:
|
|
6
|
+
|
|
7
|
+
* :func:`karni.conf.karni_settings` — assemble a full Django settings module.
|
|
8
|
+
* :func:`karni.celery.create_celery_app` — a configured Celery application.
|
|
9
|
+
* :mod:`karni.storage` — S3-compatible or local file storage plus thumbnails.
|
|
10
|
+
* :mod:`karni.health` — liveness and readiness endpoints.
|
|
11
|
+
* :mod:`karni.events` — ``<app>/listeners.py`` model lifecycle listeners
|
|
12
|
+
(``on_created`` / ``on_updated`` / ``on_deleted``, ``queued``).
|
|
13
|
+
* :mod:`karni.schedule` — ``<app>/schedules.py`` beat schedules
|
|
14
|
+
(``every`` / ``cron``).
|
|
15
|
+
|
|
16
|
+
Every default Karni applies can be overridden from the project's own
|
|
17
|
+
``settings.py``: values defined before ``karni_settings(globals())`` are never
|
|
18
|
+
overwritten, and values assigned after the call always win.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
__version__ = "0.3.1"
|
|
22
|
+
|
|
23
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Karni accounts: email-based users, one-time tokens and auth plumbing.
|
|
2
|
+
|
|
3
|
+
Installed by default via :func:`karni.conf.karni_settings` (app label
|
|
4
|
+
``karni_accounts``) together with ``AUTH_USER_MODEL = "karni_accounts.User"``.
|
|
5
|
+
|
|
6
|
+
Override / escape hatch: projects that need extra user fields subclass
|
|
7
|
+
:class:`karni.accounts.models.AbstractKarniUser` in their own app and set
|
|
8
|
+
``AUTH_USER_MODEL = "myapp.User"`` **before the first migrate** — see
|
|
9
|
+
``docs/accounts.md`` for the full swap path.
|
|
10
|
+
"""
|
karni/accounts/admin.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""Admin integration for the Karni user model and the roles (accounts contract amendment A10).
|
|
2
|
+
|
|
3
|
+
Exposes :class:`KarniUserAdmin`, an email-based ``UserAdmin`` built on
|
|
4
|
+
``karni.admin.KarniModelAdmin`` with working add/change forms (no username
|
|
5
|
+
field anywhere). When the project has not swapped the user model
|
|
6
|
+
(``AUTH_USER_MODEL == "karni_accounts.User"``) the default user is registered
|
|
7
|
+
here explicitly — ``karni.admin``'s autodiscover deliberately skips
|
|
8
|
+
``AbstractBaseUser`` subclasses (its amendment C5). Its change form carries
|
|
9
|
+
the **Effective permissions** button (ADR 0002, P5).
|
|
10
|
+
|
|
11
|
+
The role tables are registered here too: :class:`RoleAdmin` (editable, with
|
|
12
|
+
the **Permission matrix** and **Effective permissions** pages as changelist
|
|
13
|
+
buttons — ``admin:karni_accounts_role_permission_matrix`` and
|
|
14
|
+
``..._effective_permissions``), and read-only admins for ``Permission`` and
|
|
15
|
+
``RolePermission`` — grants change through the matrix and its guard rails,
|
|
16
|
+
never by editing a row.
|
|
17
|
+
|
|
18
|
+
Override / escape hatch — swapped user models reuse the class as a base::
|
|
19
|
+
|
|
20
|
+
# myapp/admin.py
|
|
21
|
+
from django.contrib import admin
|
|
22
|
+
from karni.accounts.admin import KarniUserAdmin
|
|
23
|
+
from myapp.models import User
|
|
24
|
+
|
|
25
|
+
@admin.register(User)
|
|
26
|
+
class UserAdmin(KarniUserAdmin):
|
|
27
|
+
list_display = (*KarniUserAdmin.list_display, "plan")
|
|
28
|
+
|
|
29
|
+
The class (and therefore ``karni.admin`` / django-unfold) is built lazily on
|
|
30
|
+
first attribute access, so importing this module never drags the admin stack
|
|
31
|
+
in at app-populate time.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from django.conf import settings
|
|
39
|
+
from django.contrib import admin
|
|
40
|
+
from django.urls import path
|
|
41
|
+
|
|
42
|
+
__all__ = ["KarniUserAdmin", "RoleAdmin"] # noqa: F822 - provided lazily via module __getattr__
|
|
43
|
+
|
|
44
|
+
_user_admin_class: type[Any] | None = None
|
|
45
|
+
_role_admin_classes: tuple[type[Any], type[Any], type[Any]] | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _unfold_action() -> Any:
|
|
49
|
+
"""django-unfold's ``@action`` (a button plus a URL on its ``ModelAdmin``), or a stand-in.
|
|
50
|
+
|
|
51
|
+
On another ``KARNI_ADMIN_BASE`` the stand-in only records ``url_path`` so
|
|
52
|
+
:func:`_fallback_urls` can route the page; there is no button, the URL is
|
|
53
|
+
the documented way in.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
from unfold.decorators import action
|
|
57
|
+
except ImportError: # pragma: no cover - unfold is a core dependency
|
|
58
|
+
|
|
59
|
+
def action(**options: Any) -> Any:
|
|
60
|
+
def decorate(fn: Any) -> Any:
|
|
61
|
+
fn.url_path = options.get("url_path", fn.__name__)
|
|
62
|
+
return fn
|
|
63
|
+
|
|
64
|
+
return decorate
|
|
65
|
+
|
|
66
|
+
return action
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _fallback_urls(
|
|
70
|
+
model_admin: Any, *, list_actions: tuple[str, ...] = (), detail_actions: tuple[str, ...] = ()
|
|
71
|
+
) -> list[Any]:
|
|
72
|
+
"""Routes for the page methods when the base admin is not django-unfold's.
|
|
73
|
+
|
|
74
|
+
Unfold's ``get_urls`` already routes every ``actions_list`` /
|
|
75
|
+
``actions_detail`` method; a plain ``ModelAdmin`` does not, so these
|
|
76
|
+
patterns (same names, same paths) fill in.
|
|
77
|
+
"""
|
|
78
|
+
if hasattr(model_admin, "_get_base_actions_list"):
|
|
79
|
+
return []
|
|
80
|
+
opts = model_admin.model._meta
|
|
81
|
+
wrap = model_admin.admin_site.admin_view
|
|
82
|
+
urls = []
|
|
83
|
+
for name in list_actions:
|
|
84
|
+
method = getattr(model_admin, name)
|
|
85
|
+
route = getattr(method, "url_path", name)
|
|
86
|
+
urls.append(
|
|
87
|
+
path(f"{route}/", wrap(method), name=f"{opts.app_label}_{opts.model_name}_{name}")
|
|
88
|
+
)
|
|
89
|
+
for name in detail_actions:
|
|
90
|
+
method = getattr(model_admin, name)
|
|
91
|
+
route = getattr(method, "url_path", name)
|
|
92
|
+
urls.append(
|
|
93
|
+
path(
|
|
94
|
+
f"<path:object_id>/{route}/",
|
|
95
|
+
wrap(method),
|
|
96
|
+
name=f"{opts.app_label}_{opts.model_name}_{name}",
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
return urls
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _build_user_admin() -> type[Any]:
|
|
103
|
+
"""Construct the concrete ``KarniUserAdmin`` class.
|
|
104
|
+
|
|
105
|
+
Deferred so ``karni.admin`` (written for the active ``KARNI_ADMIN_BASE``,
|
|
106
|
+
default django-unfold) and the active user model are only imported when
|
|
107
|
+
the admin is actually used.
|
|
108
|
+
"""
|
|
109
|
+
from django.contrib.auth import get_user_model
|
|
110
|
+
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
|
|
111
|
+
from django.contrib.auth.forms import UserChangeForm, UserCreationForm
|
|
112
|
+
from django.http import Http404
|
|
113
|
+
|
|
114
|
+
from karni.admin import KarniModelAdmin
|
|
115
|
+
|
|
116
|
+
action = _unfold_action()
|
|
117
|
+
user_model = get_user_model()
|
|
118
|
+
|
|
119
|
+
class KarniUserCreationForm(UserCreationForm):
|
|
120
|
+
"""Add form asking for email + password pair only (no username)."""
|
|
121
|
+
|
|
122
|
+
class Meta(UserCreationForm.Meta):
|
|
123
|
+
model = user_model
|
|
124
|
+
fields = ("email",)
|
|
125
|
+
|
|
126
|
+
class KarniUserChangeForm(UserChangeForm):
|
|
127
|
+
"""Change form bound to the active (possibly swapped) user model."""
|
|
128
|
+
|
|
129
|
+
class Meta(UserChangeForm.Meta):
|
|
130
|
+
model = user_model
|
|
131
|
+
fields = "__all__"
|
|
132
|
+
|
|
133
|
+
class KarniUserAdmin(KarniModelAdmin, DjangoUserAdmin):
|
|
134
|
+
"""Email-based user admin for ``AbstractKarniUser`` models.
|
|
135
|
+
|
|
136
|
+
Reusable base for swapped user models: subclass, extend the class
|
|
137
|
+
attributes (``list_display``, ``fieldsets``, ...) and register it for
|
|
138
|
+
your model. Every attribute is a plain Django ``UserAdmin`` knob.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
add_form = KarniUserCreationForm
|
|
142
|
+
form = KarniUserChangeForm
|
|
143
|
+
ordering = ("email",)
|
|
144
|
+
list_display = (
|
|
145
|
+
"email",
|
|
146
|
+
"first_name",
|
|
147
|
+
"last_name",
|
|
148
|
+
"is_staff",
|
|
149
|
+
"is_active",
|
|
150
|
+
"email_verified",
|
|
151
|
+
"date_joined",
|
|
152
|
+
)
|
|
153
|
+
list_filter = ("is_staff", "is_superuser", "is_active", "email_verified", "groups")
|
|
154
|
+
search_fields = ("email", "first_name", "last_name")
|
|
155
|
+
readonly_fields = ("last_login", "date_joined", "created_at", "updated_at")
|
|
156
|
+
fieldsets = (
|
|
157
|
+
(None, {"fields": ("email", "password")}),
|
|
158
|
+
("Personal info", {"fields": ("first_name", "last_name")}),
|
|
159
|
+
("Status", {"fields": ("is_active", "email_verified")}),
|
|
160
|
+
(
|
|
161
|
+
"Permissions",
|
|
162
|
+
{"fields": ("is_staff", "is_superuser", "groups", "user_permissions")},
|
|
163
|
+
),
|
|
164
|
+
(
|
|
165
|
+
"Important dates",
|
|
166
|
+
{"fields": ("last_login", "date_joined", "created_at", "updated_at")},
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
add_fieldsets = (
|
|
170
|
+
(None, {"classes": ("wide",), "fields": ("email", "password1", "password2")}),
|
|
171
|
+
)
|
|
172
|
+
actions_detail = ["effective_permissions"]
|
|
173
|
+
|
|
174
|
+
@action(
|
|
175
|
+
description="Effective permissions",
|
|
176
|
+
url_path="permissions",
|
|
177
|
+
permissions=["view"],
|
|
178
|
+
icon="shield_person",
|
|
179
|
+
)
|
|
180
|
+
def effective_permissions(self, request: Any, object_id: str) -> Any:
|
|
181
|
+
"""What this user may do, and why (ADR 0002, P5)."""
|
|
182
|
+
from karni.accounts.matrix_admin import effective_view
|
|
183
|
+
|
|
184
|
+
obj = self.get_object(request, object_id)
|
|
185
|
+
if obj is None:
|
|
186
|
+
raise Http404
|
|
187
|
+
return effective_view(self, request, user=obj)
|
|
188
|
+
|
|
189
|
+
def get_urls(self) -> Any:
|
|
190
|
+
return [
|
|
191
|
+
*_fallback_urls(self, detail_actions=("effective_permissions",)),
|
|
192
|
+
*super().get_urls(),
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
return KarniUserAdmin
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _build_role_admins() -> tuple[type[Any], type[Any], type[Any]]:
|
|
199
|
+
"""Construct ``RoleAdmin``, ``PermissionAdmin`` and ``RolePermissionAdmin`` (ADR 0002, P5)."""
|
|
200
|
+
from django.contrib.auth.models import Group
|
|
201
|
+
from django.db.models import Count, Q
|
|
202
|
+
|
|
203
|
+
from karni.admin import KarniModelAdmin
|
|
204
|
+
|
|
205
|
+
action = _unfold_action()
|
|
206
|
+
|
|
207
|
+
class RoleAdmin(KarniModelAdmin):
|
|
208
|
+
"""Roles, with the permission matrix and effective-permissions pages as buttons.
|
|
209
|
+
|
|
210
|
+
``slug`` and ``is_system`` of a declared role follow ``KARNI_ROLES``;
|
|
211
|
+
a role added here gets its Django group on save. Grants are not
|
|
212
|
+
edited on this form — the matrix has the guard rails.
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
list_display = ("slug", "name", "is_system", "grant_count")
|
|
216
|
+
search_fields = ("slug", "name")
|
|
217
|
+
fields = ("slug", "name", "description", "is_system")
|
|
218
|
+
readonly_fields = ("is_system",)
|
|
219
|
+
actions_list = ["permission_matrix", "effective_permissions"]
|
|
220
|
+
|
|
221
|
+
def get_queryset(self, request: Any) -> Any:
|
|
222
|
+
alive = Q(grants__deleted_at__isnull=True) & ~Q(grants__scope="none")
|
|
223
|
+
return (
|
|
224
|
+
super().get_queryset(request).annotate(_grant_count=Count("grants", filter=alive))
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
@admin.display(description="grants", ordering="_grant_count")
|
|
228
|
+
def grant_count(self, obj: Any) -> int:
|
|
229
|
+
return int(getattr(obj, "_grant_count", 0))
|
|
230
|
+
|
|
231
|
+
def get_readonly_fields(self, request: Any, obj: Any = None) -> Any:
|
|
232
|
+
names = list(super().get_readonly_fields(request, obj))
|
|
233
|
+
if obj is not None and obj.is_system and "slug" not in names:
|
|
234
|
+
names.append("slug")
|
|
235
|
+
return names
|
|
236
|
+
|
|
237
|
+
def save_model(self, request: Any, obj: Any, form: Any, change: bool) -> None:
|
|
238
|
+
if not obj.group_id:
|
|
239
|
+
obj.group, _ = Group.objects.get_or_create(name=obj.slug)
|
|
240
|
+
super().save_model(request, obj, form, change)
|
|
241
|
+
|
|
242
|
+
@action(
|
|
243
|
+
description="Permission matrix",
|
|
244
|
+
url_path="matrix",
|
|
245
|
+
permissions=["view"],
|
|
246
|
+
icon="grid_view",
|
|
247
|
+
)
|
|
248
|
+
def permission_matrix(self, request: Any) -> Any:
|
|
249
|
+
"""Roles × models × actions, one scope selector per cell."""
|
|
250
|
+
from karni.accounts.matrix_admin import matrix_view
|
|
251
|
+
|
|
252
|
+
return matrix_view(self, request)
|
|
253
|
+
|
|
254
|
+
@action(
|
|
255
|
+
description="Effective permissions",
|
|
256
|
+
url_path="effective",
|
|
257
|
+
permissions=["view"],
|
|
258
|
+
icon="shield_person",
|
|
259
|
+
)
|
|
260
|
+
def effective_permissions(self, request: Any) -> Any:
|
|
261
|
+
"""Pick a user, see what they may do and why."""
|
|
262
|
+
from karni.accounts.matrix_admin import effective_view
|
|
263
|
+
|
|
264
|
+
return effective_view(self, request)
|
|
265
|
+
|
|
266
|
+
def get_urls(self) -> Any:
|
|
267
|
+
return [
|
|
268
|
+
*_fallback_urls(self, list_actions=("permission_matrix", "effective_permissions")),
|
|
269
|
+
*super().get_urls(),
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
class _ReadOnlyAdmin(KarniModelAdmin):
|
|
273
|
+
"""A changelist and a detail page, nothing editable: rows are managed elsewhere."""
|
|
274
|
+
|
|
275
|
+
def has_add_permission(self, request: Any) -> bool:
|
|
276
|
+
return False
|
|
277
|
+
|
|
278
|
+
def has_change_permission(self, request: Any, obj: Any = None) -> bool:
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
def has_delete_permission(self, request: Any, obj: Any = None) -> bool:
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
class PermissionAdmin(_ReadOnlyAdmin):
|
|
285
|
+
"""``app.model:action`` rows, written by ``karni sync-roles``."""
|
|
286
|
+
|
|
287
|
+
list_display = ("__str__", "content_type", "action")
|
|
288
|
+
list_select_related = ("content_type",)
|
|
289
|
+
search_fields = ("action", "content_type__app_label", "content_type__model")
|
|
290
|
+
list_filter = ("action",)
|
|
291
|
+
|
|
292
|
+
class RolePermissionAdmin(_ReadOnlyAdmin):
|
|
293
|
+
"""Grant rows, read-only here: change them in the permission matrix."""
|
|
294
|
+
|
|
295
|
+
list_display = ("role", "permission", "scope", "scope_path", "origin")
|
|
296
|
+
list_select_related = ("role", "permission", "permission__content_type")
|
|
297
|
+
list_filter = ("origin", "scope", "role")
|
|
298
|
+
search_fields = ("role__slug", "permission__action", "permission__content_type__model")
|
|
299
|
+
actions_list = ["permission_matrix"]
|
|
300
|
+
|
|
301
|
+
@action(
|
|
302
|
+
description="Permission matrix",
|
|
303
|
+
url_path="matrix",
|
|
304
|
+
permissions=["view"],
|
|
305
|
+
icon="grid_view",
|
|
306
|
+
)
|
|
307
|
+
def permission_matrix(self, request: Any) -> Any:
|
|
308
|
+
from karni.accounts.matrix_admin import matrix_view
|
|
309
|
+
|
|
310
|
+
return matrix_view(self, request)
|
|
311
|
+
|
|
312
|
+
def get_urls(self) -> Any:
|
|
313
|
+
return [
|
|
314
|
+
*_fallback_urls(self, list_actions=("permission_matrix",)),
|
|
315
|
+
*super().get_urls(),
|
|
316
|
+
]
|
|
317
|
+
|
|
318
|
+
return RoleAdmin, PermissionAdmin, RolePermissionAdmin
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _get_user_admin_class() -> type[Any]:
|
|
322
|
+
global _user_admin_class
|
|
323
|
+
if _user_admin_class is None:
|
|
324
|
+
_user_admin_class = _build_user_admin()
|
|
325
|
+
return _user_admin_class
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _get_role_admin_classes() -> tuple[type[Any], type[Any], type[Any]]:
|
|
329
|
+
global _role_admin_classes
|
|
330
|
+
if _role_admin_classes is None:
|
|
331
|
+
_role_admin_classes = _build_role_admins()
|
|
332
|
+
return _role_admin_classes
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def __getattr__(name: str) -> Any:
|
|
336
|
+
"""Lazily expose the admin classes (mirrors ``karni.admin``'s C6 pattern)."""
|
|
337
|
+
if name == "KarniUserAdmin":
|
|
338
|
+
return _get_user_admin_class()
|
|
339
|
+
if name == "RoleAdmin":
|
|
340
|
+
return _get_role_admin_classes()[0]
|
|
341
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _register_role_admins() -> None:
|
|
345
|
+
"""Register the role tables on the configured admin site (idempotent)."""
|
|
346
|
+
from karni.accounts.models import Permission, Role, RolePermission
|
|
347
|
+
|
|
348
|
+
site_path: str = getattr(settings, "KARNI_ADMIN_SITE", "django.contrib.admin.site")
|
|
349
|
+
from karni.imports import import_setting
|
|
350
|
+
|
|
351
|
+
site: admin.AdminSite = import_setting("KARNI_ADMIN_SITE", site_path)
|
|
352
|
+
classes = _get_role_admin_classes()
|
|
353
|
+
for model, model_admin in zip((Role, Permission, RolePermission), classes, strict=True):
|
|
354
|
+
if not site.is_registered(model):
|
|
355
|
+
site.register(model, model_admin)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _register_default_user_admin() -> None:
|
|
359
|
+
"""Register the default user on the configured admin site.
|
|
360
|
+
|
|
361
|
+
Runs at import time (``django.contrib.admin`` autodiscovers this module)
|
|
362
|
+
but only when the project kept ``AUTH_USER_MODEL = "karni_accounts.User"``
|
|
363
|
+
— a swapped model registers its own subclass instead (see the module
|
|
364
|
+
docstring). Idempotent: an existing registration is left untouched.
|
|
365
|
+
"""
|
|
366
|
+
if settings.AUTH_USER_MODEL != "karni_accounts.User":
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
from django.contrib.auth import get_user_model
|
|
370
|
+
|
|
371
|
+
site_path: str = getattr(settings, "KARNI_ADMIN_SITE", "django.contrib.admin.site")
|
|
372
|
+
from karni.imports import import_setting
|
|
373
|
+
|
|
374
|
+
site: admin.AdminSite = import_setting("KARNI_ADMIN_SITE", site_path)
|
|
375
|
+
user_model = get_user_model()
|
|
376
|
+
if not site.is_registered(user_model):
|
|
377
|
+
site.register(user_model, _get_user_admin_class())
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
_register_default_user_admin()
|
|
381
|
+
_register_role_admins()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""HTTP surface of :mod:`karni.accounts`: serializers, views and throttles.
|
|
2
|
+
|
|
3
|
+
Kept import-light on purpose — importing this package must never pull in
|
|
4
|
+
models or settings-dependent code, so Django's app loading and lazy URLconf
|
|
5
|
+
imports stay safe. Import the concrete pieces from their modules::
|
|
6
|
+
|
|
7
|
+
from karni.accounts.api.serializers import MeSerializer
|
|
8
|
+
from karni.accounts.api.throttles import AccountRateThrottle
|
|
9
|
+
from karni.accounts.api.views import LoginView
|
|
10
|
+
|
|
11
|
+
Override / escape hatch: every endpoint can be replaced by declaring your own
|
|
12
|
+
``path("api/auth/<endpoint>/", YourView.as_view())`` *before*
|
|
13
|
+
``include("karni.accounts.urls")`` — first match wins. Subclass the views or
|
|
14
|
+
serializers here to change one behaviour and keep the rest.
|
|
15
|
+
"""
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""The permission matrix and effective permissions as an API (ADR 0002, P5).
|
|
2
|
+
|
|
3
|
+
The same four functions the admin page uses (:mod:`karni.accounts.matrix`),
|
|
4
|
+
so a project can build its own screen, seed the matrix in fixtures and assert
|
|
5
|
+
it in tests:
|
|
6
|
+
|
|
7
|
+
* ``GET permissions/matrix/`` — roles, models (with the scopes each may
|
|
8
|
+
take) and every cell;
|
|
9
|
+
* ``POST permissions/matrix/`` — ``{"changes": [{"role", "permission",
|
|
10
|
+
"scope"}], "dry_run": false}``; ``scope`` is a scope value, ``null`` to
|
|
11
|
+
revoke or ``"declared"`` to hand the cell back to ``KARNI_ROLES``. The
|
|
12
|
+
answer is the plan (changes and refusals); a refused request is a 400
|
|
13
|
+
with code ``E213`` and nothing written; ``dry_run`` returns the plan
|
|
14
|
+
without writing — the confirmation diff for a custom UI;
|
|
15
|
+
* ``GET permissions/effective/`` — the requester's own effective
|
|
16
|
+
permissions; ``permissions/effective/<user id>/`` anyone else's.
|
|
17
|
+
|
|
18
|
+
Gated on the grants: ``karni_accounts.rolepermission:view`` to read the
|
|
19
|
+
matrix or someone else's effective permissions, ``:change`` to post.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any, ClassVar
|
|
25
|
+
|
|
26
|
+
from django.contrib.auth import get_user_model
|
|
27
|
+
from django.core.exceptions import ValidationError as DjangoValidationError
|
|
28
|
+
from drf_spectacular.types import OpenApiTypes
|
|
29
|
+
from drf_spectacular.utils import extend_schema
|
|
30
|
+
from rest_framework import serializers, status
|
|
31
|
+
from rest_framework.exceptions import NotFound, PermissionDenied
|
|
32
|
+
from rest_framework.permissions import SAFE_METHODS, BasePermission, IsAuthenticated
|
|
33
|
+
from rest_framework.request import Request
|
|
34
|
+
from rest_framework.response import Response
|
|
35
|
+
from rest_framework.views import APIView
|
|
36
|
+
|
|
37
|
+
from karni.accounts.matrix import (
|
|
38
|
+
MATRIX_PERMISSION,
|
|
39
|
+
MatrixRefused,
|
|
40
|
+
apply_changes,
|
|
41
|
+
build_matrix,
|
|
42
|
+
effective_permissions,
|
|
43
|
+
plan_changes,
|
|
44
|
+
)
|
|
45
|
+
from karni.accounts.roles import user_can
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"EffectivePermissionsView",
|
|
49
|
+
"MatrixChangeSerializer",
|
|
50
|
+
"MatrixChangesSerializer",
|
|
51
|
+
"MatrixPermission",
|
|
52
|
+
"PermissionMatrixView",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class MatrixChangeSerializer(serializers.Serializer):
|
|
57
|
+
"""One requested cell: ``scope`` is a value, ``null`` (revoke) or ``"declared"``."""
|
|
58
|
+
|
|
59
|
+
role = serializers.CharField()
|
|
60
|
+
permission = serializers.CharField()
|
|
61
|
+
scope = serializers.CharField(allow_null=True)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class MatrixChangesSerializer(serializers.Serializer):
|
|
65
|
+
"""The ``POST`` body: the cells to change and whether to only plan."""
|
|
66
|
+
|
|
67
|
+
changes = MatrixChangeSerializer(many=True)
|
|
68
|
+
dry_run = serializers.BooleanField(default=False)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MatrixPermission(BasePermission):
|
|
72
|
+
"""``rolepermission:view`` for safe methods, ``rolepermission:change`` otherwise."""
|
|
73
|
+
|
|
74
|
+
message = "This endpoint requires the permission to view or change role permissions."
|
|
75
|
+
|
|
76
|
+
def has_permission(self, request: Request, view: Any) -> bool:
|
|
77
|
+
user = request.user
|
|
78
|
+
if user is None or not getattr(user, "is_authenticated", False):
|
|
79
|
+
return False
|
|
80
|
+
action = "view" if request.method in SAFE_METHODS else "change"
|
|
81
|
+
return user_can(user, f"{MATRIX_PERMISSION}:{action}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class PermissionMatrixView(APIView):
|
|
85
|
+
"""``GET``/``POST permissions/matrix/`` — the matrix, and changes to it."""
|
|
86
|
+
|
|
87
|
+
permission_classes: ClassVar[list[type]] = [MatrixPermission]
|
|
88
|
+
serializer_class = MatrixChangesSerializer
|
|
89
|
+
|
|
90
|
+
def get(self, request: Request) -> Response:
|
|
91
|
+
return Response(build_matrix().to_dict())
|
|
92
|
+
|
|
93
|
+
def post(self, request: Request) -> Response:
|
|
94
|
+
serializer = MatrixChangesSerializer(data=request.data)
|
|
95
|
+
serializer.is_valid(raise_exception=True)
|
|
96
|
+
requested = [
|
|
97
|
+
(change["role"], change["permission"], change["scope"])
|
|
98
|
+
for change in serializer.validated_data["changes"]
|
|
99
|
+
]
|
|
100
|
+
if serializer.validated_data["dry_run"]:
|
|
101
|
+
return Response(plan_changes(request.user, requested).to_dict())
|
|
102
|
+
try:
|
|
103
|
+
plan = apply_changes(request.user, requested)
|
|
104
|
+
except MatrixRefused as exc:
|
|
105
|
+
return Response(
|
|
106
|
+
{"code": "E213", "detail": str(exc), **exc.plan.to_dict()},
|
|
107
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
108
|
+
)
|
|
109
|
+
return Response(plan.to_dict())
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@extend_schema(responses={200: OpenApiTypes.OBJECT})
|
|
113
|
+
class EffectivePermissionsView(APIView):
|
|
114
|
+
"""``GET permissions/effective/[<user id>/]`` — what a user may do, and why."""
|
|
115
|
+
|
|
116
|
+
permission_classes: ClassVar[list[type]] = [IsAuthenticated]
|
|
117
|
+
|
|
118
|
+
def get(self, request: Request, user_id: str | None = None) -> Response:
|
|
119
|
+
target = request.user
|
|
120
|
+
if user_id is not None and str(user_id) != str(target.pk):
|
|
121
|
+
if not user_can(request.user, f"{MATRIX_PERMISSION}:view"):
|
|
122
|
+
raise PermissionDenied(
|
|
123
|
+
detail="Viewing another user's effective permissions requires "
|
|
124
|
+
f"{MATRIX_PERMISSION}:view."
|
|
125
|
+
)
|
|
126
|
+
try:
|
|
127
|
+
target = get_user_model()._default_manager.filter(pk=user_id).first()
|
|
128
|
+
except (ValueError, DjangoValidationError):
|
|
129
|
+
target = None
|
|
130
|
+
if target is None:
|
|
131
|
+
raise NotFound
|
|
132
|
+
return Response(effective_permissions(target).to_dict())
|