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