1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
//! Django-shape `ModelAdmin.has_{add,change,delete,view}_permission`
//! — issue #361.
//!
//! Django's admin invokes four per-object permission predicates
//! around the write paths:
//!
//! - `has_add_permission(request)` — gate `POST /<model>` (create)
//! - `has_change_permission(request, obj=None)` — gate the change
//! form + `POST /<model>/<pk>` (update). With `obj=None` it's a
//! collection-level test ("can the user reach the edit form?");
//! with `obj=<row>` it's per-row.
//! - `has_delete_permission(request, obj=None)` — same shape but
//! for delete.
//! - `has_view_permission(request, obj=None)` — gate the detail
//! view.
//!
//! Returning `False` raises a `PermissionDenied` (HTTP 403). This
//! is the layer Django apps use for ownership scoping, soft-locks,
//! and per-record audit-restricted access.
//!
//! rustango already gates list / write routes via the
//! `permission_required` middleware (codename-based) and the
//! `Builder::read_only` allowlist. This module adds the per-row
//! hook layer on top: an inventory-collected registry of
//! `(table, action, fn(&Parts, Option<&Value>) -> bool)` entries.
//! The admin's detail / edit / update / delete / create handlers
//! consult the registry and return 403 when any hook for the
//! relevant action denies.
//!
//! ## Usage
//!
//! ```ignore
//! use axum::http::request::Parts;
//! use serde_json::Value;
//!
//! // Only allow the row's owner to edit / delete.
//! fn owner_only(parts: &Parts, row: Option<&Value>) -> bool {
//! let Some(row) = row else { return true; }; // no object → bail
//! let user_id = parts.extensions.get::<i64>().copied().unwrap_or(0);
//! row.get("owner_id").and_then(Value::as_i64) == Some(user_id)
//! }
//! rustango::register_admin_object_permission!("blog_post", "change", owner_only);
//! rustango::register_admin_object_permission!("blog_post", "delete", owner_only);
//! ```
//!
//! Now `/admin/blog_post/<id>/edit` and the delete POST both 403
//! when the request user doesn't own the row. The `"add"` /
//! `"view"` actions still permit by default since no hooks for
//! those actions were registered.
//!
//! ## Compose
//!
//! Multiple hooks on the same `(table, action)` ALL must return
//! `true` for the action to be allowed (AND semantics). The first
//! `false` wins — short-circuit evaluation. Hooks registered
//! against a table that isn't visible (filtered out by `show_only`)
//! still run when the route is mounted; they're cheap so the
//! invariant "every reachable route honors every hook" is worth
//! more than a tiny visibility-check optimization.
//!
//! ## Action names
//!
//! Plain `&'static str` rather than an enum so the registry is
//! open-ended — future actions (`approve`, `restore`, etc.) just
//! pass a new name without touching this module. Built-in admin
//! handlers consult `"add"` / `"change"` / `"delete"` / `"view"`;
//! custom views (`register_admin_view!`) can consult their own
//! action names manually via [`is_allowed`].
use Parts;
use Value;
/// Signature of an admin object-permission hook.
///
/// `row` is `Some(&json)` for object-level actions (change /
/// delete / view) and `None` for collection-level actions (add,
/// or a change-form access check with no row yet).
///
/// Return `true` to permit, `false` to deny. Multiple hooks
/// AND together; first `false` wins.
///
/// Plain `fn` pointer (not `Arc<dyn Fn>`) — same const-storage
/// reason as every other inventory registry in this crate.
pub type ObjectPermissionFn = fn ;
/// One per-table-per-action hook registration. Inventory-collected
/// via [`crate::register_admin_object_permission!`].
collect!;
/// `true` when every registered hook for `(table, action)` returns
/// `true` (or no hooks are registered). First `false` wins —
/// short-circuit. Built-in admin handlers call this with
/// the canonical action names before letting a write proceed.
/// Register a Django-shape `has_<action>_permission` predicate
/// scoped to one model.
///
/// `$action` is the action name — typically one of `"add"`,
/// `"change"`, `"delete"`, `"view"`. Custom values are fine; the
/// built-in admin handlers consult the canonical four, and
/// downstream code can call [`is_allowed`] with whatever name
/// it likes.
///
/// ```ignore
/// fn allowed(_parts: &axum::http::request::Parts, _row: Option<&serde_json::Value>) -> bool {
/// true
/// }
/// rustango::register_admin_object_permission!("blog_post", "change", allowed);
/// ```