runique 2.2.0

A Django-inspired web framework for Rust with ORM, templates, and comprehensive security middleware
Documentation
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
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
//! Types for admin resources: columns, operations, display configuration.
//
// Resource access permissions are managed in the database via per-group scoped rights
// (eihwaz_groupes_droits: groupe_id + resource_key + CRUD matrix), and not in admin!{}.
// See: runique::auth::permissions_cache

/// Granular permissions per CRUD operation
#[derive(Debug, Clone, serde::Serialize)]
pub struct ResourcePermissions {
    // Authorized roles for each operation
    pub list: Vec<String>,

    pub view: Vec<String>,

    pub create: Vec<String>,

    pub edit: Vec<String>,

    pub delete: Vec<String>,
}

impl ResourcePermissions {
    /// Creates uniform permissions for all actions
    pub fn uniform(roles: Vec<String>) -> Self {
        Self {
            list: roles.clone(),
            view: roles.clone(),
            create: roles.clone(),
            edit: roles.clone(),
            delete: roles,
        }
    }

    /// Checks if a role is authorized for a given operation
    pub fn can(&self, operation: CrudOperation, role: &str) -> bool {
        let allowed = match operation {
            CrudOperation::List => &self.list,
            CrudOperation::View => &self.view,
            CrudOperation::Create => &self.create,
            CrudOperation::Edit => &self.edit,
            CrudOperation::Delete => &self.delete,
        };
        allowed.iter().any(|r| r == role)
    }

    /// Checks if any of the provided roles are authorized for an operation
    pub fn can_any(&self, operation: CrudOperation, roles: &[&str]) -> bool {
        roles.iter().any(|role| self.can(operation, role))
    }
}

/// Available CRUD operations on an admin resource
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum CrudOperation {
    List,
    View,
    Create,
    Edit,
    Delete,
}

/// Filters columns displayed in the list view
#[derive(Debug, Clone, Default, serde::Serialize)]
pub enum ColumnFilter {
    /// Displays all columns of the Model (default)
    #[default]
    All,

    /// Displays only specified columns with their labels: (col_sql, displayed_label)
    Include(Vec<(String, String)>),

    /// Displays all columns except specified ones
    Exclude(Vec<String>),
}

/// Declares that a resource is a **scoped child** of another resource, reached
/// only through its parent (`/{parent}/{parent_id}/{child}/...`), never at the
/// top level.
///
/// The child carries its own scoping contract (single source of truth for the FK):
/// - its list is filtered `WHERE {fk_col} = {parent_id}`,
/// - its create/edit forms fix `{fk_col}` to the parent id and hide the picker,
/// - the parent detail screen renders it as an inline sub-list.
///
/// `local_key` distinguishes two child shapes:
/// - `Some(col)` — junction table whose closure-id is `"{parent_id}:{col_value}"`
///   (e.g. `groupes_droits`, keyed by `(groupe_id, resource_key)` → `local_key =
///   Some("resource_key")`). The nested handler rebuilds the composite id from the
///   path so the CRUD closures stay unchanged, exposes only the local key in child
///   URLs, and fixes/hides both `fk_col` and `local_key` in the edit form (both are
///   the row's identity).
/// - `None` — child owns its own primary key. The closure-id is that key; the
///   handler additionally verifies the fetched row's `{fk_col} == parent_id`
///   (IDOR guard) so a child of another parent can't be reached through this one.
#[derive(Debug, Clone, serde::Serialize)]
pub struct ParentScope {
    /// Registry key of the parent resource (e.g. `"groupes"`).
    pub parent_key: &'static str,
    /// Child column holding the FK to the parent (e.g. `"groupe_id"`).
    pub fk_col: &'static str,
    /// `Some(col)` for a composite/junction child keyed by `(fk_col, col)`;
    /// `None` when the child owns its own primary key.
    pub local_key: Option<&'static str>,
}

impl ParentScope {
    /// Whether the child's closure-id is composite `"{parent_id}:{local_key}"`.
    #[must_use]
    pub fn is_composite(&self) -> bool {
        self.local_key.is_some()
    }
}

/// Configuration of resource display in the admin interface
#[derive(Debug, Clone, serde::Serialize)]
pub struct DisplayConfig {
    /// Icon displayed in navigation (icon name, e.g., "user", "file")
    pub icon: Option<String>,

    /// Columns to display in the list view
    pub columns: ColumnFilter,

    /// Number of entries per page
    pub pagination: usize,

    /// Sidebar filters: [(col_sql, displayed_label, limit_per_page)]
    pub list_filter: Vec<(String, String, u64)>,
}

impl DisplayConfig {
    pub fn new() -> Self {
        Self {
            icon: None,
            columns: ColumnFilter::All,
            pagination: 25,
            list_filter: Vec::new(),
        }
    }

    pub fn icon(mut self, icon: &str) -> Self {
        self.icon = Some(icon.to_string());
        self
    }

    pub fn pagination(mut self, per_page: usize) -> Self {
        self.pagination = per_page;
        self
    }

    pub fn columns_include(mut self, cols: Vec<(&str, &str)>) -> Self {
        self.columns = ColumnFilter::Include(
            cols.iter()
                .map(|(c, l)| (c.to_string(), l.to_string()))
                .collect(),
        );
        self
    }

    pub fn columns_exclude(mut self, cols: Vec<&str>) -> Self {
        self.columns = ColumnFilter::Exclude(cols.iter().map(|s| s.to_string()).collect());
        self
    }

    /// Sidebar filters: [("col_sql", "Label", limit_per_page), ...]
    pub fn list_filter(mut self, filters: Vec<(&str, &str, u64)>) -> Self {
        self.list_filter = filters
            .iter()
            .map(|(c, l, limit)| (c.to_string(), l.to_string(), *limit))
            .collect();
        self
    }
}

impl Default for DisplayConfig {
    fn default() -> Self {
        Self::new()
    }
}

// Created by the daemon during parsing of src/admin.rs.
//
// generated in target/runique/admin/generated.rs to be type-safe.

/// Metadata of an administrable resource
#[derive(Debug, Clone, serde::Serialize)]
pub struct AdminResource {
    /// Used for routes: /admin/{key}/list
    pub key: &'static str,

    /// We retrieve the paths for model and form
    pub model_path: &'static str,

    pub form_path: &'static str,

    /// Title displayed in the admin interface
    pub title: &'static str,

    /// CRUD permissions for this resource
    pub permissions: ResourcePermissions,

    /// Display configuration (columns, pagination, icon)
    pub display: DisplayConfig,

    /// Template overrides per operation (None = default Runique template)
    pub template_list: Option<String>,
    pub template_create: Option<String>,
    pub template_edit: Option<String>,
    pub template_detail: Option<String>,
    pub template_delete: Option<String>,

    /// Custom keys injected into the Tera context (defined via extra: {} in admin!{})
    pub extra_context: std::collections::HashMap<String, String>,

    /// If true: injects a random hash into the empty "password" field upon creation.
    /// Automatically set by the daemon when `create_form:` is declared.
    pub inject_password: bool,

    /// FK columns to resolve to a related label in **display** views (list,
    /// detail, delete) — `[(col, fk_table, label_col)]`. Resolution happens at
    /// the display layer (never in `get_fn`/`list_fn`) so edit forms keep the
    /// raw id and pre-select the right option. Emitted by the daemon.
    pub fk_display: Vec<(String, String, String)>,

    /// When set, this resource is a scoped child reached only through its parent
    /// (`/{parent}/{parent_id}/{child}/...`). See [`ParentScope`]. `None` = a
    /// normal top-level resource.
    pub parent_scope: Option<ParentScope>,
}

impl AdminResource {
    pub fn new(
        key: &'static str,
        model_path: &'static str,
        form_path: &'static str,
        title: &'static str,
        roles: Vec<String>,
    ) -> Self {
        Self {
            key,
            model_path,
            form_path,
            title,
            permissions: ResourcePermissions::uniform(roles),
            display: DisplayConfig::new(),
            template_list: None,
            template_create: None,
            template_edit: None,
            template_detail: None,
            template_delete: None,
            extra_context: std::collections::HashMap::new(),
            inject_password: false,
            fk_display: Vec::new(),
            parent_scope: None,
        }
    }

    /// Creates a resource with granular permissions
    pub fn with_permissions(
        key: &'static str,
        model_path: &'static str,
        form_path: &'static str,
        title: &'static str,
        permissions: ResourcePermissions,
    ) -> Self {
        Self {
            key,
            model_path,
            form_path,
            title,
            permissions,
            display: DisplayConfig::new(),
            template_list: None,
            template_create: None,
            template_edit: None,
            template_detail: None,
            template_delete: None,
            extra_context: std::collections::HashMap::new(),
            inject_password: false,
            fk_display: Vec::new(),
            parent_scope: None,
        }
    }

    /// Enables automatic injection of a random hash into the empty "password" field upon creation.
    pub fn inject_password(mut self, v: bool) -> Self {
        self.inject_password = v;
        self
    }

    /// Declares the FK columns resolved to a label in display views.
    /// `specs` = `[(col, fk_table, label_col)]`. Emitted by the daemon.
    #[must_use]
    pub fn fk_display(mut self, specs: Vec<(String, String, String)>) -> Self {
        self.fk_display = specs;
        self
    }

    /// Declares this resource as a scoped child of `parent_key`, reached only
    /// through `/{parent_key}/{parent_id}/{key}/...`. See [`ParentScope`].
    ///
    /// `local_key`: `Some(col)` for a composite/junction child keyed by
    /// `(fk_col, col)`; `None` when the child owns its own primary key.
    #[must_use]
    pub fn parent_scope(
        mut self,
        parent_key: &'static str,
        fk_col: &'static str,
        local_key: Option<&'static str>,
    ) -> Self {
        self.parent_scope = Some(ParentScope {
            parent_key,
            fk_col,
            local_key,
        });
        self
    }

    /// Configures the display of this resource
    pub fn display(mut self, display: DisplayConfig) -> Self {
        self.display = display;
        self
    }

    /// Returns the list route path for this resource
    ///
    /// Ex: resource.key = "users" → "/users/list"
    pub fn list_route(&self) -> String {
        format!("/{}/list", self.key)
    }

    /// Returns the creation route path for this resource
    pub fn create_route(&self) -> String {
        format!("/{}/create", self.key)
    }

    /// Returns the detail/edit route path for this resource
    pub fn detail_route(&self) -> String {
        format!("/{}/{{id}}", self.key)
    }

    /// Returns the delete route path for this resource
    pub fn delete_route(&self) -> String {
        format!("/{}/{{id}}/delete", self.key)
    }

    // ─── Template resolution (fallback to Runique defaults) ───

    pub fn resolve_list(&self) -> &str {
        self.template_list.as_deref().unwrap_or("admin/list.html")
    }

    pub fn resolve_create(&self) -> &str {
        self.template_create
            .as_deref()
            .unwrap_or("admin/create.html")
    }

    pub fn resolve_edit(&self) -> &str {
        self.template_edit.as_deref().unwrap_or("admin/edit.html")
    }

    pub fn resolve_detail(&self) -> &str {
        self.template_detail
            .as_deref()
            .unwrap_or("admin/detail.html")
    }

    pub fn resolve_delete(&self) -> &str {
        self.template_delete
            .as_deref()
            .unwrap_or("admin/delete.html")
    }

    // ─── Builder methods ──────────────────────────────────────────

    pub fn template_list(mut self, path: &str) -> Self {
        self.template_list = Some(path.to_string());
        self
    }

    pub fn template_create(mut self, path: &str) -> Self {
        self.template_create = Some(path.to_string());
        self
    }

    pub fn template_edit(mut self, path: &str) -> Self {
        self.template_edit = Some(path.to_string());
        self
    }

    pub fn template_detail(mut self, path: &str) -> Self {
        self.template_detail = Some(path.to_string());
        self
    }

    pub fn template_delete(mut self, path: &str) -> Self {
        self.template_delete = Some(path.to_string());
        self
    }

    pub fn extra(mut self, key: &str, value: &str) -> Self {
        self.extra_context
            .insert(key.to_string(), value.to_string());
        self
    }

    pub fn extra_map(mut self, map: std::collections::HashMap<String, String>) -> Self {
        self.extra_context.extend(map);
        self
    }
}