Skip to main content

umbral_auth/
session_user.rs

1//! `AuthUser`-aware session helpers — moved from umbral-sessions so
2//! sessions can stay free of any user-model dependency.
3//!
4//! The split mirrors the dep arrow: `umbral-auth` depends on
5//! `umbral-sessions` (it needs cookie + session-table primitives),
6//! `umbral-sessions` does not depend on `umbral-auth` (it knows
7//! nothing about users). All the AuthUser hydration happens here.
8//!
9//! ## What this module owns
10//!
11//! - [`current_user`] — read the cookie, hydrate the [`AuthUser`].
12//! - [`login`] / [`login_with_request`] — the one-call shape
13//!   for credential check + session creation + cookie set +
14//!   `last_login` bump.
15//! - [`logout`] — re-exported convenience; same as
16//!   `umbral_sessions::logout` plus a forwarding doc-comment.
17//! - [`SessionAuthentication`] — the `umbral-rest` `Authentication`
18//!   impl that produces an `Identity` for the permission layer
19//!   (was in `umbral-sessions`; needs AuthUser to populate
20//!   `is_staff`).
21//! - [`User`] / [`OptionalUser`] — axum extractors that pull
22//!   `AuthUser` from the request.
23//! - [`user_context_layer`] — middleware that injects the current
24//!   user into `umbral::templates::CURRENT_USER` so HTML templates
25//!   can write `{% if user.is_authenticated %}` uniformly.
26//!
27//! ## Custom user models
28//!
29//! Everything in here is hard-bound to [`AuthUser`]. Apps using a
30//! custom [`UserModel`] roll their own helpers — the building
31//! blocks are all `pub`:
32//!
33//! - `umbral_sessions::current_user_id_str(&headers)` → user PK as
34//!   a string (already user-agnostic).
35//! - Their own user lookup against that PK.
36//! - Their own `Identity` builder.
37//!
38//! [`UserModel`]: crate::UserModel
39
40use crate::{AuthUser, auth_user};
41use async_trait::async_trait;
42use axum_core::extract::FromRequestParts;
43use http::StatusCode;
44use http::request::Parts;
45use umbral::auth::{Authentication, Identity};
46use umbral::web::HeaderMap;
47use umbral_sessions::SessionError;
48
49// =========================================================================
50// current_user — the AuthUser-flavored wrapper around
51// umbral_sessions::current_session.
52// =========================================================================
53
54/// Read the request's session cookie, look up the session row, then
55/// hydrate the [`AuthUser`] it points at. Returns `None` for any
56/// of: no cookie, expired session, anonymous session
57/// (`user_id IS NULL`), parse failure on a non-i64 user_id, missing
58/// user row, or inactive user.
59///
60/// One DB read (session row) + one DB read (user row). The
61/// `is_active` predicate is part of the user query, so a deactivated
62/// account silently looks anonymous from this helper's perspective
63/// without an explicit second filter at the call site.
64pub async fn current_user(headers: &HeaderMap) -> Result<Option<AuthUser>, SessionError> {
65    let Some(user_id_str) = umbral_sessions::current_user_id_str(headers).await? else {
66        return Ok(None);
67    };
68    // Session.user_id is text (gap #59) — parse back to AuthUser's
69    // i64 PK. A non-parseable value means the session was written
70    // by a different UserModel impl; from AuthUser's perspective
71    // that's anonymous.
72    let Ok(user_id) = user_id_str.parse::<i64>() else {
73        return Ok(None);
74    };
75    let user: Option<AuthUser> = AuthUser::objects()
76        .filter(auth_user::ID.eq(user_id) & auth_user::IS_ACTIVE.eq(true))
77        .first()
78        .await?;
79    Ok(user)
80}
81
82// =========================================================================
83// login / login_with_request — credential check ran outside, we just
84// mint the session + cookie + bump last_login.
85// =========================================================================
86
87/// Convenience: [`login_with_request`] with an empty request
88/// HeaderMap. Use when the handler doesn't already have a
89/// `HeaderMap` extractor and you're not worried about preserving
90/// an anonymous session's `data` (flash messages, cart) across the
91/// login.
92pub async fn login(
93    response_headers: &mut HeaderMap,
94    user: &AuthUser,
95) -> Result<String, SessionError> {
96    login_with_request(&HeaderMap::new(), response_headers, user).await
97}
98
99/// Mint an authenticated session for `user`, rotate the cookie, and
100/// bump `auth_user.last_login`. The session-fixation defense fires
101/// inside `umbral_sessions::login_user_id`: any anonymous session
102/// the request carried is destroyed before the new authenticated
103/// row is written.
104///
105/// `last_login` is a best-effort update: a failure logs a warning
106/// but doesn't invalidate the login (the session was created and
107/// the cookie was set, so the user is in).
108pub async fn login_with_request(
109    request_headers: &HeaderMap,
110    response_headers: &mut HeaderMap,
111    user: &AuthUser,
112) -> Result<String, SessionError> {
113    let token = umbral_sessions::login_user_id(
114        request_headers,
115        response_headers,
116        Some(user.id.to_string()),
117    )
118    .await?;
119
120    let mut patch = serde_json::Map::new();
121    patch.insert(
122        "last_login".to_string(),
123        serde_json::to_value(chrono::Utc::now()).unwrap_or(serde_json::Value::Null),
124    );
125    if let Err(e) = AuthUser::objects()
126        .filter(auth_user::ID.eq(user.id))
127        .update_values(patch)
128        .await
129    {
130        tracing::warn!(
131            error = ?e,
132            user_id = user.id,
133            "umbral-auth::login: failed to update last_login (session still active)",
134        );
135    }
136    Ok(token)
137}
138
139// =========================================================================
140// SessionAuthentication — produce an `Identity` for the REST
141// permission layer.
142// =========================================================================
143
144/// The session-cookie authenticator for `umbral-rest`. Reads the
145/// cookie, hydrates the [`AuthUser`], turns it into an [`Identity`]
146/// with `is_staff` set. Same shape `current_user` produces, packaged
147/// for `RestPlugin::authenticate`.
148///
149/// Was in `umbral-sessions` before the de-coupling; now here so it
150/// can name `AuthUser`.
151#[derive(Debug, Default, Clone, Copy)]
152pub struct SessionAuthentication;
153
154impl SessionAuthentication {
155    /// Convenience constructor identical to `Default::default()`.
156    pub fn new() -> Self {
157        Self
158    }
159}
160
161#[async_trait]
162impl Authentication for SessionAuthentication {
163    async fn authenticate(&self, headers: &HeaderMap) -> Option<Identity> {
164        let user = current_user(headers).await.ok().flatten()?;
165        // `user.id_string()` is the UserModel-level stringifier —
166        // it stays correct when an app swaps AuthUser for a custom
167        // user model with a non-i64 PK. `Identity::user_id` is
168        // String regardless because Identity must be uniform across
169        // user models.
170        Some(
171            Identity::user(crate::UserModel::id_string(&user))
172                .with_staff(user.is_staff)
173                .with_superuser(user.is_superuser)
174                .with_extra("auth", serde_json::json!("session")),
175        )
176    }
177
178    fn security_scheme(&self) -> Option<(String, serde_json::Value)> {
179        // Standard "session cookie" scheme. The actual cookie name
180        // (`umbral_session`) is documented in the description so
181        // Swagger UI users know what they're authorising with.
182        Some((
183            "SessionAuth".to_string(),
184            serde_json::json!({
185                "type": "apiKey",
186                "in": "cookie",
187                "name": "umbral_session",
188                "description": "umbral session cookie. Set by `POST /api/auth/login`; cleared by `/logout`."
189            }),
190        ))
191    }
192}
193
194// =========================================================================
195// User / OptionalUser axum extractors. Same shapes that used to live
196// in umbral-sessions::extractors.
197// =========================================================================
198
199/// Required-user extractor. 401 on anonymous requests.
200///
201/// ```ignore
202/// async fn dashboard(User(user): User) -> Html<String> {
203///     Html(format!("Welcome, {}!", user.username))
204/// }
205/// ```
206#[derive(Debug, Clone)]
207pub struct User(pub AuthUser);
208
209/// Optional-user extractor. Anonymous requests get `None`.
210///
211/// ```ignore
212/// async fn home(OptionalUser(maybe): OptionalUser) -> Html<String> {
213///     match maybe {
214///         Some(u) => Html(format!("Hi, {}", u.username)),
215///         None    => Html("<a href=\"/login\">Log in</a>".into()),
216///     }
217/// }
218/// ```
219#[derive(Debug, Clone)]
220pub struct OptionalUser(pub Option<AuthUser>);
221
222impl<S> FromRequestParts<S> for User
223where
224    S: Send + Sync,
225{
226    type Rejection = (StatusCode, &'static str);
227
228    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
229        match current_user(&parts.headers).await.ok().flatten() {
230            Some(u) => Ok(User(u)),
231            None => Err((StatusCode::UNAUTHORIZED, "authentication required")),
232        }
233    }
234}
235
236impl<S> FromRequestParts<S> for OptionalUser
237where
238    S: Send + Sync,
239{
240    type Rejection = std::convert::Infallible;
241
242    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
243        Ok(OptionalUser(
244            current_user(&parts.headers).await.ok().flatten(),
245        ))
246    }
247}
248
249// =========================================================================
250// Template-injection middleware. Stash the current user under the
251// `umbral::templates::CURRENT_USER` task-local so HTML renders can
252// pick it up as `{{ user }}`.
253// =========================================================================
254
255/// Install a per-request lazy resolver on the
256/// [`umbral::templates::CURRENT_USER_LAZY`] channel.
257///
258/// The resolver clones the request headers and runs **at most once**,
259/// only when a template actually accesses `{{ user }}`. Requests that
260/// never render a template (JSON/API responses) pay zero DB reads.
261/// When the resolver does run it performs the session + user lookup
262/// and memoizes the result for the rest of the request.
263///
264/// Opt in via [`crate::AuthPlugin::with_user_in_templates`] when the
265/// app is HTML-heavy; leave off for REST-only services.
266pub async fn user_context_layer(
267    axum::extract::State(expand_lists): axum::extract::State<std::sync::Arc<Vec<String>>>,
268    req: axum::extract::Request,
269    next: axum::middleware::Next,
270) -> axum::response::Response {
271    // Install a LAZY resolver instead of resolving eagerly. The closure runs
272    // (at most once) only if a template actually reads `user`; a JSON/API
273    // response that never renders the template pays nothing.
274    let headers = req.headers().clone();
275    let lazy = umbral::templates::LazyUser::new(move || {
276        let headers = headers.clone();
277        // features #84: the opt-in reverse-FK-list tables the AuthPlugin
278        // recorded, threaded into the expansion. Empty (the default) means
279        // no lists — identical to the pre-#84 behaviour.
280        let expand_lists = expand_lists.clone();
281        async move {
282            match current_user(&headers).await {
283                Ok(Some(u)) => serialize_authenticated_with_relations(&u, &expand_lists).await,
284                _ => anonymous_user_value(),
285            }
286        }
287    });
288    umbral::templates::with_current_user_lazy(lazy, next.run(req)).await
289}
290
291/// Middleware that publishes the authenticated user's id to this request's
292/// database connection as a Postgres session variable (gaps3 #45).
293///
294/// The Postgres pool's acquire hook already ran `set_config(name, value, false)`
295/// for every entry in `RouteContext::session_vars`, and `umbral-rls` policies
296/// already read `current_setting('app.user_id')`. What was missing was anything
297/// that *filled* that list: the only hook that could, `AppBuilder::route_context`,
298/// takes a **synchronous** resolver, and finding the session user needs an async
299/// DB read.
300///
301/// So this layer augments the context rather than building it — it clones
302/// whatever the outer resolver produced (tenant, other variables), adds one
303/// entry, and re-scopes for the rest of the request. `RouteContext::add_session_var`
304/// was written for exactly this and had no callers until now.
305///
306/// The variable is always set, to the empty string for an anonymous caller.
307/// Postgres raises `unrecognized configuration parameter` when `current_setting`
308/// names a GUC that was never set on the connection, so leaving it unset would
309/// make every logged-out request a 500 rather than an empty result set. Policies
310/// should read `NULLIF(current_setting('app.user_id'), '')`.
311///
312/// Generic over the user model, and `resolve_user` filters on `is_active`, so a
313/// deactivated account publishes no identity.
314///
315/// Opt in via [`crate::AuthPlugin::with_db_session_var`].
316pub async fn db_session_var_layer<U>(
317    axum::extract::State(var_name): axum::extract::State<std::sync::Arc<str>>,
318    req: axum::extract::Request,
319    next: axum::middleware::Next,
320) -> axum::response::Response
321where
322    U: crate::UserModel
323        + for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
324        + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
325        + umbral::orm::HydrateRelated
326        + Unpin
327        + Send,
328    <U as umbral::orm::Model>::PrimaryKey: std::str::FromStr,
329{
330    let user_id = crate::login_required::resolve_user::<U>(req.headers())
331        .await
332        .map(|u| u.id_string())
333        .unwrap_or_default();
334
335    let mut ctx = (*umbral::db::route_context::current()).clone();
336    ctx.add_session_var(var_name.as_ref(), user_id);
337    umbral::db::route_context_scope(ctx, next.run(req)).await
338}
339
340/// Depth cap for the recursive relation expansion in
341/// [`serialize_authenticated_with_relations`]. Closes gap2 #14:
342/// templates can write `user.customer.loyalty_points` and get the
343/// resolved value without the handler having to declare the prefetch.
344///
345/// Why 2: covers the common case `user.<one_to_one>.<scalar>` (1 hop
346/// to load the child, scalars are free) AND `user.<one_to_one>.<fk>`
347/// (2 hops if the template wants to walk back into another object).
348/// Beyond 2 the query budget grows with the graph fan-out and the
349/// "templates pay for every relation, every request" trade-off
350/// stops being honest.
351const USER_RELATION_DEPTH: usize = 2;
352
353/// Hard row cap on an injected reverse-FK one-to-many list (features
354/// #84). `user.order_set` is a per-request query; a user with 50k
355/// orders must not load all of them into every template render. The
356/// list is `LIMIT`-ed to this many rows — a bounded, opt-in cost that
357/// keeps the "one query per relation per request" contract honest.
358/// Callers who need more paginate in the handler.
359const USER_REVERSE_FK_LIST_CAP: u64 = 20;
360
361async fn serialize_authenticated_with_relations(
362    user: &AuthUser,
363    expand_lists: &[String],
364) -> umbral::templates::Value {
365    let mut json = match serde_json::to_value(user) {
366        Ok(serde_json::Value::Object(map)) => map,
367        _ => serde_json::Map::new(),
368    };
369    json.insert(
370        "is_authenticated".to_string(),
371        serde_json::Value::Bool(true),
372    );
373
374    // gap2 #14: recursively expand reverse-O2O and forward-FK
375    // relations on the serialized user, up to `USER_RELATION_DEPTH`
376    // hops, with `(table, pk)` cycle detection so
377    // `user.customer.user.customer...` terminates.
378    //
379    // PK lift Pass C: the visited set keys on `(table_name,
380    // pk_json_key(value))` so non-i64 user PKs (UUID-keyed
381    // AuthUser variants, codename-keyed permissions, etc.) ride
382    // through the same cycle detector. The pre-fix shape was
383    // `HashSet<(String, i64)>` which silently coerced everything
384    // to i64 and broke for any UserModel impl with a non-i64 PK.
385    //
386    // The auth_user table must exist in the registry (AuthPlugin
387    // registers it during App::build); if for some reason it's
388    // missing we silently fall back to the un-expanded user JSON
389    // rather than failing the request.
390    let registered = umbral::migrate::registered_models();
391    if let Some(meta) = registered.iter().find(|m| m.table == "auth_user") {
392        let mut visited: std::collections::HashSet<(String, String)> =
393            std::collections::HashSet::new();
394        let seed_pk = serde_json::Value::Number(user.id.into());
395        visited.insert(("auth_user".to_string(), pk_json_key(&seed_pk)));
396        expand_relations(
397            meta,
398            &registered,
399            &mut json,
400            USER_RELATION_DEPTH,
401            &mut visited,
402            expand_lists,
403        )
404        .await;
405    }
406
407    umbral::templates::Value::from_serialize(serde_json::Value::Object(json))
408}
409
410/// Recursive depth-bounded expansion of a row's FK relations
411/// (gap2 #14). Mutates `row` in place to:
412///
413/// - Replace every forward-FK integer id with the resolved target
414///   row (when known to the model registry).
415/// - Inject every reverse-OneToOne candidate (child models with a
416///   UNIQUE FK pointing at `meta`) under the child table's name as
417///   the key — so `Customer { user: ForeignKey<AuthUser> (unique) }`
418///   surfaces as `user.customer` on the parent.
419///
420/// `visited` carries `(table, pk)` pairs already loaded in this
421/// expansion. New rows are checked against it before recursion and
422/// inserted before descending, so any cycle in the FK graph
423/// terminates at the first revisit.
424///
425/// One query per loaded relation per request — the middleware's
426/// query budget grows by `count(relations within depth)`, not by
427/// the fan-out of subsequent template renders. Sparse relation
428/// graphs (the common case) add 1-3 queries; pathological graphs
429/// hit the depth cap and stop.
430fn expand_relations<'a>(
431    meta: &'a umbral::migrate::ModelMeta,
432    registered: &'a [umbral::migrate::ModelMeta],
433    row: &'a mut serde_json::Map<String, serde_json::Value>,
434    depth: usize,
435    visited: &'a mut std::collections::HashSet<(String, String)>,
436    expand_lists: &'a [String],
437) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
438    Box::pin(async move {
439        if depth == 0 {
440            return;
441        }
442
443        // -- Forward FKs: replace integer / string / UUID ids with
444        // the full target row. Mirrors the dynamic
445        // `select_related_dyn` semantics (gap2 #15) but driven by
446        // the registry walk here so the user middleware doesn't
447        // need to know which columns to expand ahead of time.
448        //
449        // PK lift Pass C: read the FK value as `serde_json::Value`
450        // (not i64) so non-integer-PK targets (codename-keyed
451        // permissions, UUID-keyed custom user models, etc.) flow
452        // through. The cycle key uses `pk_json_key` so a numeric
453        // 42 and a string "42" stay in different buckets.
454        let forward_fks: Vec<(String, String, serde_json::Value)> = meta
455            .fields
456            .iter()
457            .filter_map(|col| {
458                let target_table = col.fk_target.as_deref()?;
459                let fk_val = row.get(&col.name)?.clone();
460                if fk_val.is_null() {
461                    return None;
462                }
463                Some((col.name.clone(), target_table.to_string(), fk_val))
464            })
465            .collect();
466        for (col_name, target_table, fk_val) in forward_fks {
467            let visit_key = (target_table.clone(), pk_json_key(&fk_val));
468            if visited.contains(&visit_key) {
469                continue;
470            }
471            let Some(target_meta) = registered.iter().find(|m| m.table == target_table) else {
472                continue;
473            };
474            let Some(target_pk) = target_meta.pk_column() else {
475                continue;
476            };
477            let fetched = umbral::orm::DynQuerySet::for_meta(target_meta)
478                .filter_eq_string(&target_pk.name, &json_value_to_pk_string(&fk_val))
479                .first_as_json()
480                .await;
481            let Ok(Some(mut target_row)) = fetched else {
482                continue;
483            };
484            visited.insert(visit_key);
485            expand_relations(
486                target_meta,
487                registered,
488                &mut target_row,
489                depth - 1,
490                visited,
491                expand_lists,
492            )
493            .await;
494            row.insert(col_name, serde_json::Value::Object(target_row));
495        }
496
497        // -- Reverse-O2O: child models with a UNIQUE FK to this
498        // table get injected under the child's table name. Naming
499        // convention uses the lower-case-model-name idiom
500        // (`Customer { user: FK<User> (unique) }` → `user.customer`).
501        let Some(parent_pk_col) = meta.pk_column() else {
502            return;
503        };
504        // PK lift Pass C: parent PK as `serde_json::Value`, not i64.
505        let parent_pk = match row.get(&parent_pk_col.name).cloned() {
506            Some(v) if !v.is_null() => v,
507            _ => return,
508        };
509        let candidates: Vec<(&umbral::migrate::ModelMeta, String)> = registered
510            .iter()
511            .filter_map(|child| {
512                // Need exactly one UNIQUE FK pointing at this
513                // table; ambiguous matches (e.g. `primary_user` +
514                // `backup_user` both UNIQUE FKs to auth_user) are
515                // skipped — there's no single right answer for
516                // which one becomes `user.customer`.
517                let mut matches = child
518                    .fields
519                    .iter()
520                    .filter(|c| c.fk_target.as_deref() == Some(&meta.table) && c.unique);
521                let first = matches.next()?;
522                if matches.next().is_some() {
523                    return None;
524                }
525                Some((child, first.name.clone()))
526            })
527            .collect();
528        for (child_meta, fk_col_name) in candidates {
529            // Don't clobber a same-named scalar on the parent — if
530            // a model genuinely names a column the same as its
531            // child table (rare), the existing column wins.
532            if row.contains_key(&child_meta.table) {
533                continue;
534            }
535            let fetched = umbral::orm::DynQuerySet::for_meta(child_meta)
536                .filter_eq_string(&fk_col_name, &json_value_to_pk_string(&parent_pk))
537                .first_as_json()
538                .await;
539            let Ok(Some(mut child_row)) = fetched else {
540                continue;
541            };
542            let Some(child_pk_col) = child_meta.pk_column() else {
543                continue;
544            };
545            // PK lift Pass C: child PK as `serde_json::Value`.
546            let Some(child_pk) = child_row.get(&child_pk_col.name).cloned() else {
547                continue;
548            };
549            if child_pk.is_null() {
550                continue;
551            }
552            let visit_key = (child_meta.table.clone(), pk_json_key(&child_pk));
553            if visited.contains(&visit_key) {
554                continue;
555            }
556            visited.insert(visit_key);
557            expand_relations(
558                child_meta,
559                registered,
560                &mut child_row,
561                depth - 1,
562                visited,
563                expand_lists,
564            )
565            .await;
566            row.insert(
567                child_meta.table.clone(),
568                serde_json::Value::Object(child_row),
569            );
570        }
571
572        // -- Reverse-FK one-to-many lists (features #84): opt-in only.
573        // For each child table the app declared via
574        // `with_user_in_templates().expand_list::<Child>()`, inject up
575        // to `USER_REVERSE_FK_LIST_CAP` child rows (ordered by PK) that
576        // point at THIS row through a FK, under `<child_table>_set`.
577        //
578        // Scoped by FK target: `order_set` only appears where `order`
579        // actually has a FK to `meta.table`, so the same opt-in surfaces
580        // `user.order_set` on the user but not on an unrelated parent.
581        //
582        // List items are the flat child rows — deliberately NOT expanded
583        // further. One query per opted-in list per level, regardless of
584        // how many rows it holds, keeps the per-request cost honest and
585        // predictable (a nested expansion would multiply by the cap).
586        for child_table in expand_lists {
587            let list_key = format!("{child_table}_set");
588            if row.contains_key(&list_key) {
589                continue;
590            }
591            let Some(child_meta) = registered.iter().find(|m| &m.table == child_table) else {
592                continue;
593            };
594            // The child's FK column that points back at THIS table. If
595            // it doesn't reference `meta.table`, this list doesn't belong
596            // on this row (that's the FK-target scoping).
597            let Some(fk_col) = child_meta
598                .fields
599                .iter()
600                .find(|c| c.fk_target.as_deref() == Some(&meta.table))
601            else {
602                continue;
603            };
604            let rows = umbral::orm::DynQuerySet::for_meta(child_meta)
605                .filter_eq_string(&fk_col.name, &json_value_to_pk_string(&parent_pk))
606                .limit(USER_REVERSE_FK_LIST_CAP)
607                .fetch_as_json()
608                .await;
609            let Ok(rows) = rows else {
610                continue;
611            };
612            row.insert(
613                list_key,
614                serde_json::Value::Array(rows.into_iter().map(serde_json::Value::Object).collect()),
615            );
616        }
617    })
618}
619
620/// PK lift Pass C: stable cycle-key for the `visited` HashSet in
621/// [`expand_relations`]. `serde_json::Value` isn't `Hash`, so we
622/// flatten to a namespaced `String` per shape. Mirrors the
623/// `pk_json_key` helper in `umbral-core::orm::dynamic` — kept local
624/// here to avoid widening `umbral-core`'s pub surface for one tiny
625/// helper. If a third call site needs the same namespacing, the
626/// two should converge into one canonical pub fn in the facade.
627fn pk_json_key(v: &serde_json::Value) -> String {
628    match v {
629        serde_json::Value::Number(n) => format!("n:{n}"),
630        serde_json::Value::String(s) => format!("s:{s}"),
631        other => format!("o:{other}"),
632    }
633}
634
635/// Render a PK JSON value as the string `DynQuerySet::filter_eq_string`
636/// expects to bind against. `filter_eq_string` already coerces per the
637/// column's `SqlType` so the right operand type lands on the wire —
638/// we just need to hand it the value's `Display` form.
639fn json_value_to_pk_string(v: &serde_json::Value) -> String {
640    match v {
641        serde_json::Value::Number(n) => n.to_string(),
642        serde_json::Value::String(s) => s.clone(),
643        other => other.to_string(),
644    }
645}
646
647fn anonymous_user_value() -> umbral::templates::Value {
648    let mut json = serde_json::Map::new();
649    json.insert(
650        "is_authenticated".to_string(),
651        serde_json::Value::Bool(false),
652    );
653    umbral::templates::Value::from_serialize(serde_json::Value::Object(json))
654}
655
656// logout is now a proper `pub async fn` in `crate` (lib.rs) that wraps
657// `umbral_sessions::logout` and maps the error to `AuthError::Session`.
658// It is the single reusable logout for all surfaces. The old forwarding
659// alias that returned `SessionError` has been removed.