Skip to main content

assay_auth/
admin.rs

1//! Cross-cutting admin HTTP API.
2//!
3//! Phase 8b adds admin endpoints for user / session / Zanzibar / key
4//! management. Each endpoint requires an admin api-key (compared in
5//! constant time against [`crate::state::AdminApiKeys`]) — same auth
6//! pattern as [`crate::oidc_provider::admin`].
7//!
8//! Surface (mounted under `/api/v1/engine/auth/` by the engine, so the
9//! actual paths are `/api/v1/engine/auth/admin/...`):
10//!
11//! - `GET    /admin/users?limit=&offset=&search=`
12//! - `POST   /admin/users`            → mint user
13//! - `GET    /admin/users/{id}`       → user + linked passkeys + sessions + upstream
14//! - `PUT    /admin/users/{id}`       → update email / display_name / verified
15//! - `DELETE /admin/users/{id}`       → cascade delete via FKs
16//! - `POST   /admin/users/{id}/password-reset` → set new password (admin override)
17//!
18//! - `GET    /admin/sessions?limit=&offset=&user_id=`
19//! - `DELETE /admin/sessions/{id}`
20//! - `DELETE /admin/sessions/by-user/{user_id}` → revoke all
21//!
22//! - `GET    /admin/biscuit`          → active root key info (kid + public PEM)
23//! - `GET    /admin/jwks`             → JWKS public document (proxy /well-known)
24//!
25//! - `GET    /admin/zanzibar/namespaces`
26//! - `POST   /admin/zanzibar/namespaces`           → define / replace schema
27//! - `GET    /admin/zanzibar/namespaces/{name}`
28//! - `POST   /admin/zanzibar/tuples`              → write
29//! - `DELETE /admin/zanzibar/tuples`              → delete
30//! - `POST   /admin/zanzibar/check`               → permission check
31//! - `POST   /admin/zanzibar/expand`              → userset tree
32//!
33//! - `GET    /admin/audit?limit=&offset=&actor=&action=`
34//!   → empty response today (audit table is deferred per V1 schema notes)
35
36use axum::Router;
37use axum::extract::{FromRef, Path, Query, State};
38use axum::http::{HeaderMap, StatusCode};
39use axum::response::{IntoResponse, Json, Response};
40use axum::routing::{delete, get, post};
41use serde::{Deserialize, Serialize};
42use serde_json::json;
43
44use crate::ctx::AuthCtx;
45use crate::state::AdminApiKeys;
46use crate::store::User;
47
48/// Build the cross-cutting admin router. Generic over a parent state
49/// `S` from which `AuthCtx` and [`AdminApiKeys`] can be extracted via
50/// `axum::extract::FromRef`.
51pub fn router<S>() -> Router<S>
52where
53    S: Clone + Send + Sync + 'static,
54    AuthCtx: FromRef<S>,
55    AdminApiKeys: FromRef<S>,
56{
57    Router::new()
58        .route("/admin/users", get(list_users).post(create_user_handler))
59        .route(
60            "/admin/users/{id}",
61            get(get_user_detail)
62                .put(update_user_handler)
63                .delete(delete_user_handler),
64        )
65        .route(
66            "/admin/users/{id}/password-reset",
67            post(password_reset_handler),
68        )
69        .route("/admin/sessions", get(list_sessions))
70        .route("/admin/sessions/{id}", delete(revoke_session))
71        .route(
72            "/admin/sessions/by-user/{user_id}",
73            delete(revoke_sessions_for_user),
74        )
75        .route("/admin/biscuit", get(biscuit_info))
76        .route("/admin/jwks", get(jwks_proxy))
77        .route(
78            "/admin/zanzibar/namespaces",
79            get(zanzibar_list_namespaces).post(zanzibar_define_namespace),
80        )
81        .route(
82            "/admin/zanzibar/namespaces/{name}",
83            get(zanzibar_get_namespace),
84        )
85        .route(
86            "/admin/zanzibar/tuples",
87            post(zanzibar_write_tuple).delete(zanzibar_delete_tuple),
88        )
89        .route("/admin/zanzibar/check", post(zanzibar_check_handler))
90        .route("/admin/zanzibar/expand", post(zanzibar_expand_handler))
91        .route("/admin/audit", get(audit_list))
92}
93
94/// Auth + Zanzibar gate shared by every admin handler. Resolves a
95/// [`crate::gate::Caller`] from the request, then enforces the
96/// `auth#system#admin` role. Admin api-key callers are accepted as
97/// break-glass and bypass the Zanzibar lookup.
98///
99/// Returns the resolved caller on success — currently unused by these
100/// handlers but kept so future audit-log integration can reach for it.
101/// `Response` is ~272 bytes; the boxed error keeps the success path
102/// small (every admin handler calls this on the hot path). Callers
103/// unbox with `*r` before returning.
104async fn require_admin(
105    headers: &HeaderMap,
106    ctx: &AuthCtx,
107    keys: &AdminApiKeys,
108) -> Result<crate::gate::Caller, Box<Response>> {
109    crate::gate::require_role_for(headers, ctx, keys, "auth", "system", "admin").await
110}
111
112// =====================================================================
113//   /admin/users
114// =====================================================================
115
116#[derive(Clone, Debug, Default, Deserialize)]
117pub struct ListUsersQuery {
118    #[serde(default)]
119    pub limit: Option<i64>,
120    #[serde(default)]
121    pub offset: Option<i64>,
122    #[serde(default)]
123    pub search: Option<String>,
124}
125
126#[derive(Clone, Debug, Serialize)]
127pub struct ListUsersResponse {
128    pub items: Vec<User>,
129    pub total: i64,
130    pub limit: i64,
131    pub offset: i64,
132}
133
134async fn list_users(
135    State(ctx): State<AuthCtx>,
136    State(keys): State<AdminApiKeys>,
137    headers: HeaderMap,
138    Query(q): Query<ListUsersQuery>,
139) -> Response {
140    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
141        return *r;
142    }
143    let limit = q.limit.unwrap_or(50).clamp(1, 500);
144    let offset = q.offset.unwrap_or(0).max(0);
145    let search = q.search.as_deref();
146    let items = match ctx.users.list_users(limit, offset, search).await {
147        Ok(v) => v,
148        Err(e) => return server_error(&format!("list users: {e}")),
149    };
150    let total = match ctx.users.count_users(search).await {
151        Ok(n) => n,
152        Err(e) => return server_error(&format!("count users: {e}")),
153    };
154    (
155        StatusCode::OK,
156        Json(ListUsersResponse {
157            items,
158            total,
159            limit,
160            offset,
161        }),
162    )
163        .into_response()
164}
165
166#[derive(Clone, Debug, Deserialize)]
167pub struct CreateUserBody {
168    pub email: Option<String>,
169    pub display_name: Option<String>,
170    #[serde(default)]
171    pub email_verified: bool,
172    /// Optional initial password. When present, hashed via Argon2id and
173    /// stored on the user row. When `None`, the user has no password and
174    /// must enrol a passkey or federate-in to authenticate.
175    pub password: Option<String>,
176}
177
178async fn create_user_handler(
179    State(ctx): State<AuthCtx>,
180    State(keys): State<AdminApiKeys>,
181    headers: HeaderMap,
182    Json(body): Json<CreateUserBody>,
183) -> Response {
184    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
185        return *r;
186    }
187    let id = format!(
188        "usr_{}",
189        data_encoding::BASE64URL_NOPAD.encode(&random_bytes::<12>())
190    );
191    let user = User {
192        id: id.clone(),
193        email: body.email,
194        email_verified: body.email_verified,
195        display_name: body.display_name,
196        created_at: now_secs(),
197    };
198    if let Err(e) = ctx.users.create_user(&user).await {
199        return server_error(&format!("create user: {e}"));
200    }
201    if let Some(pw) = body.password.as_ref() {
202        #[cfg(feature = "auth-password")]
203        {
204            match crate::password::PasswordHasher::default().hash(pw) {
205                Ok(hash) => {
206                    if let Err(e) = ctx.users.set_password_hash(&id, &hash).await {
207                        return server_error(&format!("set password hash: {e}"));
208                    }
209                }
210                Err(e) => return server_error(&format!("hash password: {e}")),
211            }
212        }
213        #[cfg(not(feature = "auth-password"))]
214        {
215            let _ = pw;
216            return svc_unavailable("auth-password feature not compiled in");
217        }
218    }
219    (StatusCode::CREATED, Json(user)).into_response()
220}
221
222#[derive(Clone, Debug, Serialize)]
223pub struct UserDetailResponse {
224    pub user: User,
225    pub passkeys: Vec<PasskeySummary>,
226    pub sessions: Vec<crate::store::Session>,
227    pub upstream: Vec<UpstreamLink>,
228}
229
230#[derive(Clone, Debug, Serialize)]
231pub struct PasskeySummary {
232    pub credential_id: String,
233    pub sign_count: u32,
234    pub transports: Vec<String>,
235    pub created_at: f64,
236}
237
238#[derive(Clone, Debug, Serialize)]
239pub struct UpstreamLink {
240    pub provider: String,
241    pub subject: String,
242}
243
244async fn get_user_detail(
245    State(ctx): State<AuthCtx>,
246    State(keys): State<AdminApiKeys>,
247    headers: HeaderMap,
248    Path(id): Path<String>,
249) -> Response {
250    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
251        return *r;
252    }
253    let user = match ctx.users.get_user_by_id(&id).await {
254        Ok(Some(u)) => u,
255        Ok(None) => {
256            return (
257                StatusCode::NOT_FOUND,
258                Json(json!({"error": "unknown user_id"})),
259            )
260                .into_response();
261        }
262        Err(e) => return server_error(&format!("get user: {e}")),
263    };
264    let passkeys = match ctx.users.list_passkeys(&id).await {
265        Ok(v) => v
266            .into_iter()
267            .map(|p| PasskeySummary {
268                credential_id: data_encoding::BASE64URL_NOPAD.encode(&p.credential_id),
269                sign_count: p.sign_count,
270                transports: p.transports,
271                created_at: p.created_at,
272            })
273            .collect(),
274        Err(e) => return server_error(&format!("list passkeys: {e}")),
275    };
276    let sessions = match ctx.sessions.list_for_user(&id).await {
277        Ok(v) => v,
278        Err(e) => return server_error(&format!("list sessions: {e}")),
279    };
280    let upstream = match ctx.users.list_upstream_for_user(&id).await {
281        Ok(v) => v
282            .into_iter()
283            .map(|(provider, subject)| UpstreamLink { provider, subject })
284            .collect(),
285        Err(e) => return server_error(&format!("list upstream: {e}")),
286    };
287    (
288        StatusCode::OK,
289        Json(UserDetailResponse {
290            user,
291            passkeys,
292            sessions,
293            upstream,
294        }),
295    )
296        .into_response()
297}
298
299#[derive(Clone, Debug, Deserialize)]
300pub struct UpdateUserBody {
301    pub email: Option<String>,
302    pub display_name: Option<String>,
303    pub email_verified: Option<bool>,
304}
305
306async fn update_user_handler(
307    State(ctx): State<AuthCtx>,
308    State(keys): State<AdminApiKeys>,
309    headers: HeaderMap,
310    Path(id): Path<String>,
311    Json(body): Json<UpdateUserBody>,
312) -> Response {
313    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
314        return *r;
315    }
316    let mut user = match ctx.users.get_user_by_id(&id).await {
317        Ok(Some(u)) => u,
318        Ok(None) => {
319            return (
320                StatusCode::NOT_FOUND,
321                Json(json!({"error": "unknown user_id"})),
322            )
323                .into_response();
324        }
325        Err(e) => return server_error(&format!("get user: {e}")),
326    };
327    if let Some(email) = body.email {
328        user.email = Some(email);
329    }
330    if let Some(name) = body.display_name {
331        user.display_name = Some(name);
332    }
333    if let Some(v) = body.email_verified {
334        user.email_verified = v;
335    }
336    if let Err(e) = ctx.users.update_user(&user).await {
337        return server_error(&format!("update user: {e}"));
338    }
339    (StatusCode::OK, Json(user)).into_response()
340}
341
342async fn delete_user_handler(
343    State(ctx): State<AuthCtx>,
344    State(keys): State<AdminApiKeys>,
345    headers: HeaderMap,
346    Path(id): Path<String>,
347) -> Response {
348    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
349        return *r;
350    }
351    match ctx.users.delete_user(&id).await {
352        Ok(true) => StatusCode::NO_CONTENT.into_response(),
353        Ok(false) => (
354            StatusCode::NOT_FOUND,
355            Json(json!({"error": "unknown user_id"})),
356        )
357            .into_response(),
358        Err(e) => server_error(&format!("delete user: {e}")),
359    }
360}
361
362#[derive(Clone, Debug, Deserialize)]
363pub struct PasswordResetBody {
364    /// New plaintext password — hashed by the handler via Argon2id and
365    /// persisted on the user row. Admin-driven; bypasses the usual
366    /// "old password" check that a self-service flow would enforce.
367    pub password: String,
368}
369
370async fn password_reset_handler(
371    State(ctx): State<AuthCtx>,
372    State(keys): State<AdminApiKeys>,
373    headers: HeaderMap,
374    Path(id): Path<String>,
375    Json(body): Json<PasswordResetBody>,
376) -> Response {
377    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
378        return *r;
379    }
380    if ctx
381        .users
382        .get_user_by_id(&id)
383        .await
384        .unwrap_or(None)
385        .is_none()
386    {
387        return (
388            StatusCode::NOT_FOUND,
389            Json(json!({"error": "unknown user_id"})),
390        )
391            .into_response();
392    }
393    #[cfg(feature = "auth-password")]
394    {
395        let hash = match crate::password::PasswordHasher::default().hash(&body.password) {
396            Ok(h) => h,
397            Err(e) => return server_error(&format!("hash password: {e}")),
398        };
399        if let Err(e) = ctx.users.set_password_hash(&id, &hash).await {
400            return server_error(&format!("set password hash: {e}"));
401        }
402        StatusCode::NO_CONTENT.into_response()
403    }
404    #[cfg(not(feature = "auth-password"))]
405    {
406        let _ = body.password;
407        let _ = ctx;
408        svc_unavailable("auth-password feature not compiled in")
409    }
410}
411
412// =====================================================================
413//   /admin/sessions
414// =====================================================================
415
416#[derive(Clone, Debug, Default, Deserialize)]
417pub struct ListSessionsQuery {
418    #[serde(default)]
419    pub limit: Option<i64>,
420    #[serde(default)]
421    pub offset: Option<i64>,
422    #[serde(default)]
423    pub user_id: Option<String>,
424}
425
426#[derive(Clone, Debug, Serialize)]
427pub struct ListSessionsResponse {
428    pub items: Vec<crate::store::Session>,
429    pub total: i64,
430    pub limit: i64,
431    pub offset: i64,
432}
433
434async fn list_sessions(
435    State(ctx): State<AuthCtx>,
436    State(keys): State<AdminApiKeys>,
437    headers: HeaderMap,
438    Query(q): Query<ListSessionsQuery>,
439) -> Response {
440    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
441        return *r;
442    }
443    let limit = q.limit.unwrap_or(50).clamp(1, 500);
444    let offset = q.offset.unwrap_or(0).max(0);
445    let user_filter = q.user_id.as_deref();
446    let items = match ctx.sessions.list_all(limit, offset, user_filter).await {
447        Ok(v) => v,
448        Err(e) => return server_error(&format!("list sessions: {e}")),
449    };
450    let total = match ctx.sessions.count_all(user_filter).await {
451        Ok(n) => n,
452        Err(e) => return server_error(&format!("count sessions: {e}")),
453    };
454    (
455        StatusCode::OK,
456        Json(ListSessionsResponse {
457            items,
458            total,
459            limit,
460            offset,
461        }),
462    )
463        .into_response()
464}
465
466async fn revoke_session(
467    State(ctx): State<AuthCtx>,
468    State(keys): State<AdminApiKeys>,
469    headers: HeaderMap,
470    Path(id): Path<String>,
471) -> Response {
472    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
473        return *r;
474    }
475    match ctx.sessions.delete(&id).await {
476        Ok(true) => StatusCode::NO_CONTENT.into_response(),
477        Ok(false) => (
478            StatusCode::NOT_FOUND,
479            Json(json!({"error": "unknown session_id"})),
480        )
481            .into_response(),
482        Err(e) => server_error(&format!("revoke session: {e}")),
483    }
484}
485
486#[derive(Clone, Debug, Serialize)]
487pub struct RevokeAllResponse {
488    pub revoked: u64,
489}
490
491async fn revoke_sessions_for_user(
492    State(ctx): State<AuthCtx>,
493    State(keys): State<AdminApiKeys>,
494    headers: HeaderMap,
495    Path(user_id): Path<String>,
496) -> Response {
497    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
498        return *r;
499    }
500    match ctx.sessions.delete_for_user(&user_id).await {
501        Ok(n) => (StatusCode::OK, Json(RevokeAllResponse { revoked: n })).into_response(),
502        Err(e) => server_error(&format!("revoke for user: {e}")),
503    }
504}
505
506// =====================================================================
507//   /admin/biscuit + /admin/jwks
508// =====================================================================
509
510#[derive(Clone, Debug, Serialize)]
511pub struct BiscuitInfo {
512    pub kid: String,
513    pub public_pem: String,
514}
515
516async fn biscuit_info(
517    State(ctx): State<AuthCtx>,
518    State(keys): State<AdminApiKeys>,
519    headers: HeaderMap,
520) -> Response {
521    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
522        return *r;
523    }
524    let kid = ctx.biscuit.active_kid();
525    let public_pem = match ctx.biscuit.public_pem() {
526        Ok(s) => s,
527        Err(e) => return server_error(&format!("public pem: {e}")),
528    };
529    (StatusCode::OK, Json(BiscuitInfo { kid, public_pem })).into_response()
530}
531
532async fn jwks_proxy(
533    State(ctx): State<AuthCtx>,
534    State(keys): State<AdminApiKeys>,
535    headers: HeaderMap,
536) -> Response {
537    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
538        return *r;
539    }
540    // The OIDC provider's JWKS endpoint already enumerates the active
541    // signing key — reuse its shape so admin tooling sees the same
542    // payload non-admin discovery would. When the OIDC provider isn't
543    // wired, return an empty key set.
544    #[cfg(feature = "auth-oidc-provider")]
545    {
546        if let Some(provider) = ctx.oidc_provider.as_ref() {
547            let payload = match &provider.jwks_source {
548                #[cfg(feature = "backend-postgres")]
549                crate::oidc_provider::JwksSource::Postgres(_) => json!({"keys": []}),
550                #[cfg(feature = "backend-sqlite")]
551                crate::oidc_provider::JwksSource::Sqlite(_) => json!({"keys": []}),
552                crate::oidc_provider::JwksSource::Memory(v) => json!({"keys": v}),
553            };
554            return (StatusCode::OK, Json(payload)).into_response();
555        }
556    }
557    let _ = ctx;
558    (StatusCode::OK, Json(json!({"keys": []}))).into_response()
559}
560
561// =====================================================================
562//   /admin/zanzibar
563// =====================================================================
564
565async fn zanzibar_list_namespaces(
566    State(ctx): State<AuthCtx>,
567    State(keys): State<AdminApiKeys>,
568    headers: HeaderMap,
569) -> Response {
570    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
571        return *r;
572    }
573    #[cfg(feature = "auth-zanzibar")]
574    {
575        let Some(store) = ctx.zanzibar.as_ref() else {
576            return svc_unavailable("zanzibar not enabled");
577        };
578        return match store.list_namespaces().await {
579            Ok(v) => (StatusCode::OK, Json(v)).into_response(),
580            Err(e) => server_error(&format!("list namespaces: {e}")),
581        };
582    }
583    #[cfg(not(feature = "auth-zanzibar"))]
584    {
585        let _ = ctx;
586        svc_unavailable("zanzibar not compiled in")
587    }
588}
589
590async fn zanzibar_get_namespace(
591    State(ctx): State<AuthCtx>,
592    State(keys): State<AdminApiKeys>,
593    headers: HeaderMap,
594    Path(name): Path<String>,
595) -> Response {
596    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
597        return *r;
598    }
599    #[cfg(feature = "auth-zanzibar")]
600    {
601        let Some(store) = ctx.zanzibar.as_ref() else {
602            return svc_unavailable("zanzibar not enabled");
603        };
604        return match store.get_namespace(&name).await {
605            Ok(Some(ns)) => (StatusCode::OK, Json(ns)).into_response(),
606            Ok(None) => (
607                StatusCode::NOT_FOUND,
608                Json(json!({"error": "unknown namespace"})),
609            )
610                .into_response(),
611            Err(e) => server_error(&format!("get namespace: {e}")),
612        };
613    }
614    #[cfg(not(feature = "auth-zanzibar"))]
615    {
616        let _ = (ctx, name);
617        svc_unavailable("zanzibar not compiled in")
618    }
619}
620
621async fn zanzibar_define_namespace(
622    State(ctx): State<AuthCtx>,
623    State(keys): State<AdminApiKeys>,
624    headers: HeaderMap,
625    Json(schema): Json<crate::zanzibar::NamespaceSchema>,
626) -> Response {
627    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
628        return *r;
629    }
630    #[cfg(feature = "auth-zanzibar")]
631    {
632        let Some(store) = ctx.zanzibar.as_ref() else {
633            return svc_unavailable("zanzibar not enabled");
634        };
635        return match store.define_namespace(&schema).await {
636            Ok(()) => (
637                StatusCode::CREATED,
638                Json(json!({"ok": true, "name": schema.name})),
639            )
640                .into_response(),
641            Err(e) => server_error(&format!("define namespace: {e}")),
642        };
643    }
644    #[cfg(not(feature = "auth-zanzibar"))]
645    {
646        let _ = (ctx, schema);
647        svc_unavailable("zanzibar not compiled in")
648    }
649}
650
651#[derive(Clone, Debug, Deserialize)]
652pub struct TupleBody {
653    pub object_type: String,
654    pub object_id: String,
655    pub relation: String,
656    pub subject_type: String,
657    pub subject_id: String,
658    /// Empty string (or omitted) for direct subjects; the userset
659    /// relation name for userset subjects. See `zanzibar::SubjectRef`.
660    #[serde(default)]
661    pub subject_rel: String,
662}
663
664async fn zanzibar_write_tuple(
665    State(ctx): State<AuthCtx>,
666    State(keys): State<AdminApiKeys>,
667    headers: HeaderMap,
668    Json(body): Json<TupleBody>,
669) -> Response {
670    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
671        return *r;
672    }
673    #[cfg(feature = "auth-zanzibar")]
674    {
675        let Some(store) = ctx.zanzibar.as_ref() else {
676            return svc_unavailable("zanzibar not enabled");
677        };
678        let tuple = body_to_tuple(body);
679        return match store.write_tuple(&tuple).await {
680            Ok(()) => (StatusCode::CREATED, Json(json!({"ok": true}))).into_response(),
681            Err(e) => server_error(&format!("write tuple: {e}")),
682        };
683    }
684    #[cfg(not(feature = "auth-zanzibar"))]
685    {
686        let _ = (ctx, body);
687        svc_unavailable("zanzibar not compiled in")
688    }
689}
690
691async fn zanzibar_delete_tuple(
692    State(ctx): State<AuthCtx>,
693    State(keys): State<AdminApiKeys>,
694    headers: HeaderMap,
695    Json(body): Json<TupleBody>,
696) -> Response {
697    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
698        return *r;
699    }
700    #[cfg(feature = "auth-zanzibar")]
701    {
702        let Some(store) = ctx.zanzibar.as_ref() else {
703            return svc_unavailable("zanzibar not enabled");
704        };
705        let tuple = body_to_tuple(body);
706        return match store.delete_tuple(&tuple).await {
707            Ok(true) => StatusCode::NO_CONTENT.into_response(),
708            Ok(false) => (
709                StatusCode::NOT_FOUND,
710                Json(json!({"error": "tuple not found"})),
711            )
712                .into_response(),
713            Err(e) => server_error(&format!("delete tuple: {e}")),
714        };
715    }
716    #[cfg(not(feature = "auth-zanzibar"))]
717    {
718        let _ = (ctx, body);
719        svc_unavailable("zanzibar not compiled in")
720    }
721}
722
723#[derive(Clone, Debug, Deserialize)]
724pub struct CheckBody {
725    pub resource_type: String,
726    pub resource_id: String,
727    pub permission: String,
728    pub subject_type: String,
729    pub subject_id: String,
730    /// Empty string (or omitted) for direct subjects; the userset
731    /// relation name for userset subjects.
732    #[serde(default)]
733    pub subject_rel: String,
734}
735
736#[derive(Clone, Debug, Serialize)]
737pub struct CheckResponse {
738    pub result: String,
739    pub allowed: bool,
740}
741
742async fn zanzibar_check_handler(
743    State(ctx): State<AuthCtx>,
744    State(keys): State<AdminApiKeys>,
745    headers: HeaderMap,
746    Json(body): Json<CheckBody>,
747) -> Response {
748    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
749        return *r;
750    }
751    #[cfg(feature = "auth-zanzibar")]
752    {
753        use crate::zanzibar::{CheckResult, Consistency, ObjectRef, SubjectRef};
754        let Some(store) = ctx.zanzibar.as_ref() else {
755            return svc_unavailable("zanzibar not enabled");
756        };
757        let resource = ObjectRef {
758            object_type: body.resource_type,
759            object_id: body.resource_id,
760        };
761        let subject = SubjectRef {
762            subject_type: body.subject_type,
763            subject_id: body.subject_id,
764            subject_rel: body.subject_rel,
765        };
766        return match store
767            .check(&resource, &body.permission, &subject, Consistency::Minimum)
768            .await
769        {
770            Ok(r) => {
771                let (label, allowed) = match &r {
772                    CheckResult::Allowed { .. } => ("Allowed", true),
773                    CheckResult::Denied => ("Denied", false),
774                    CheckResult::DepthExceeded => ("DepthExceeded", false),
775                    CheckResult::CycleDetected => ("CycleDetected", false),
776                };
777                (
778                    StatusCode::OK,
779                    Json(CheckResponse {
780                        result: label.to_string(),
781                        allowed,
782                    }),
783                )
784                    .into_response()
785            }
786            Err(e) => server_error(&format!("check: {e}")),
787        };
788    }
789    #[cfg(not(feature = "auth-zanzibar"))]
790    {
791        let _ = (ctx, body);
792        svc_unavailable("zanzibar not compiled in")
793    }
794}
795
796#[derive(Clone, Debug, Deserialize)]
797pub struct ExpandBody {
798    pub resource_type: String,
799    pub resource_id: String,
800    pub relation: String,
801    #[serde(default)]
802    pub depth_limit: Option<u32>,
803}
804
805async fn zanzibar_expand_handler(
806    State(ctx): State<AuthCtx>,
807    State(keys): State<AdminApiKeys>,
808    headers: HeaderMap,
809    Json(body): Json<ExpandBody>,
810) -> Response {
811    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
812        return *r;
813    }
814    #[cfg(feature = "auth-zanzibar")]
815    {
816        use crate::zanzibar::{MAX_DEPTH, ObjectRef};
817        let Some(store) = ctx.zanzibar.as_ref() else {
818            return svc_unavailable("zanzibar not enabled");
819        };
820        let resource = ObjectRef {
821            object_type: body.resource_type,
822            object_id: body.resource_id,
823        };
824        let depth = body.depth_limit.unwrap_or(MAX_DEPTH);
825        return match store.expand(&resource, &body.relation, depth).await {
826            Ok(tree) => (StatusCode::OK, Json(tree)).into_response(),
827            Err(e) => server_error(&format!("expand: {e}")),
828        };
829    }
830    #[cfg(not(feature = "auth-zanzibar"))]
831    {
832        let _ = (ctx, body);
833        svc_unavailable("zanzibar not compiled in")
834    }
835}
836
837// =====================================================================
838//   /admin/audit
839// =====================================================================
840
841#[derive(Clone, Debug, Default, Deserialize)]
842pub struct ListAuditQuery {
843    #[serde(default)]
844    pub limit: Option<i64>,
845    #[serde(default)]
846    pub offset: Option<i64>,
847    #[serde(default)]
848    pub actor: Option<String>,
849    #[serde(default)]
850    pub action: Option<String>,
851    #[serde(default)]
852    pub since: Option<f64>,
853    #[serde(default)]
854    pub until: Option<f64>,
855}
856
857#[derive(Clone, Debug, Serialize)]
858pub struct AuditResponse {
859    pub items: Vec<serde_json::Value>,
860    pub total: i64,
861    pub limit: i64,
862    pub offset: i64,
863    /// `false` until the `auth.audit` table is materialised (see
864    /// `crate::schema` notes — deferred to a later phase). The
865    /// dashboard renders an empty-state with this value to explain
866    /// the missing rows.
867    pub enabled: bool,
868}
869
870async fn audit_list(
871    State(ctx): State<AuthCtx>,
872    State(keys): State<AdminApiKeys>,
873    headers: HeaderMap,
874    Query(q): Query<ListAuditQuery>,
875) -> Response {
876    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
877        return *r;
878    }
879    let limit = q.limit.unwrap_or(50).clamp(1, 500);
880    let offset = q.offset.unwrap_or(0).max(0);
881    (
882        StatusCode::OK,
883        Json(AuditResponse {
884            items: Vec::new(),
885            total: 0,
886            limit,
887            offset,
888            enabled: false,
889        }),
890    )
891        .into_response()
892}
893
894// =====================================================================
895//   helpers
896// =====================================================================
897
898#[cfg(feature = "auth-zanzibar")]
899fn body_to_tuple(body: TupleBody) -> crate::zanzibar::Tuple {
900    crate::zanzibar::Tuple {
901        object_type: body.object_type,
902        object_id: body.object_id,
903        relation: body.relation,
904        subject_type: body.subject_type,
905        subject_id: body.subject_id,
906        subject_rel: body.subject_rel,
907    }
908}
909
910fn server_error(msg: &str) -> Response {
911    (
912        StatusCode::INTERNAL_SERVER_ERROR,
913        Json(json!({"error": "server_error", "error_description": msg})),
914    )
915        .into_response()
916}
917
918fn svc_unavailable(msg: &str) -> Response {
919    (
920        StatusCode::SERVICE_UNAVAILABLE,
921        Json(json!({"error": "service_unavailable", "error_description": msg})),
922    )
923        .into_response()
924}
925
926fn now_secs() -> f64 {
927    use std::time::{SystemTime, UNIX_EPOCH};
928    SystemTime::now()
929        .duration_since(UNIX_EPOCH)
930        .unwrap_or_default()
931        .as_secs_f64()
932}
933
934fn random_bytes<const N: usize>() -> [u8; N] {
935    use rand::RngCore;
936    let mut buf = [0u8; N];
937    rand::rng().fill_bytes(&mut buf);
938    buf
939}
940
941// Admin-gate behaviour is covered in `crate::gate::tests` and the
942// integration-test suite — the local `require_admin` is now a
943// one-line wrapper, so a per-handler test would duplicate gate.rs's
944// coverage without exercising new code paths.