Skip to main content

cheers_axum/
admin.rs

1//! `POST /admin/service-principals` + `POST /admin/service-principals/{id}/rotate`
2//! — the operator-only path that mints service-principal Ed25519 keypairs.
3//!
4//! Lives separately from [`mcp`](crate::mcp) because the bearer is **different
5//! in kind**: admin endpoints take a *session* bearer (verified through
6//! [`EdgeVerifier::verify_at`] into a [`Claims`]), not an MCP token. The
7//! distinction matters because the principal model is different: a session
8//! identifies a *user* (an operator passkey-authenticated their browser), so
9//! the gate is "is this user on the operator list" — not "does this token
10//! carry the right scope".
11//!
12//! ## What gets returned, exactly once
13//!
14//! The successful provision/rotate response carries a base64url-no-pad copy of
15//! the 64-byte Ed25519 secret. Cheers retains only the public half (in the
16//! [`ServicePrincipalStore`]); the secret is unrecoverable after this
17//! response. Operators are expected to write it to the consumer's config dir
18//! (mode 0600) before navigating away. Rotation issues a fresh secret and
19//! retires the old key into the JWKS overlap window
20//! ([`OverlapPolicy`](cheers_server::OverlapPolicy)).
21//!
22//! ## OperatorPolicy
23//!
24//! `OperatorPolicy` is a tiny `Send + Sync` trait so the product can wire
25//! whatever check it likes: a hardcoded `HashSet<UserId>`, a query against
26//! its own roles table, an LDAP lookup. Kept in `cheers-axum` (not
27//! `cheers-server`) because the check is *HTTP-route-shaped* — handlers
28//! consult it directly with the verified `claims.sub` and convert a `false`
29//! into [`RouteError::NotOperator`] (403).
30//!
31//! ## Wiring
32//!
33//! ```no_run
34//! # use std::sync::Arc;
35//! # use std::collections::HashSet;
36//! # use axum::Router;
37//! # use cheers_axum::admin::{router, AdminAuthState, OperatorPolicy};
38//! # use cheers_core::{TokenVerifier, UserId};
39//! # use cheers_server::{EdgeVerifier, RevocationReader, ServicePrincipalAuthority,
40//! #     ServicePrincipalStore};
41//! struct AllowList { ids: HashSet<UserId> }
42//! impl OperatorPolicy for AllowList {
43//!     fn is_operator(&self, user: &UserId) -> bool { self.ids.contains(user) }
44//! }
45//! # async fn run<V, Rd, S>(
46//! #     edge: Arc<EdgeVerifier<V, Rd>>,
47//! #     authority: Arc<ServicePrincipalAuthority<S>>,
48//! #     allow: Arc<AllowList>,
49//! # ) -> Result<(), Box<dyn std::error::Error>>
50//! # where
51//! #     V: TokenVerifier + Send + Sync + 'static,
52//! #     Rd: RevocationReader + 'static,
53//! #     S: ServicePrincipalStore + 'static,
54//! # {
55//! let state = Arc::new(AdminAuthState { edge, authority, operators: allow });
56//! let app: Router = Router::new().nest("/api", router(state));
57//! # Ok(()) }
58//! ```
59
60use std::sync::Arc;
61
62use axum::Json;
63use axum::Router;
64use axum::extract::{Path, State};
65use axum::http::{HeaderMap, StatusCode};
66use axum::routing::post;
67use base64::Engine as _;
68use base64::engine::general_purpose::URL_SAFE_NO_PAD;
69use serde::{Deserialize, Serialize};
70
71use cheers_core::{Principal, PrincipalId, TokenVerifier, UserId};
72use cheers_server::{
73    EdgeVerifier, NewServicePrincipal, ProvisionedKey, RevocationReader,
74    ServicePrincipalAuthority, ServicePrincipalStore, SigningKey,
75};
76
77use crate::error::RouteError;
78use crate::me::authenticate;
79
80/// Operator gate consulted by every admin handler after session
81/// authentication. Returns `true` iff `user` is allowed to call operator
82/// endpoints. The product supplies the impl (a hardcoded set, a roles
83/// table, whatever fits).
84pub trait OperatorPolicy: Send + Sync {
85    fn is_operator(&self, user: &UserId) -> bool;
86}
87
88/// State bundle held by the admin handlers.
89///
90/// Generic over the same `V`/`Rd` shape [`MeAuthState`](crate::me::MeAuthState)
91/// is generic over (so the same `EdgeVerifier` configuration works for both),
92/// plus `S` for the [`ServicePrincipalStore`] impl backing the authority.
93/// `operators` is `dyn` so the product can change the policy impl without
94/// re-genericifying every call site.
95pub struct AdminAuthState<V, Rd, S> {
96    pub edge: Arc<EdgeVerifier<V, Rd>>,
97    pub authority: Arc<ServicePrincipalAuthority<S>>,
98    pub operators: Arc<dyn OperatorPolicy>,
99}
100
101impl<V, Rd, S> std::fmt::Debug for AdminAuthState<V, Rd, S> {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        f.debug_struct("AdminAuthState").finish_non_exhaustive()
104    }
105}
106
107/// Body for `POST /admin/service-principals`.
108///
109/// `desired_id` becomes the bare half of `svc:<desired_id>`. A collision on
110/// that id surfaces as [`RouteError::AlreadyExists`] (409) — operators retry
111/// with a different name rather than rotate (rotate is for an *existing*
112/// principal).
113#[derive(Debug, Clone, Deserialize)]
114pub struct CreateServicePrincipalBody {
115    pub desired_id: String,
116}
117
118/// One-shot provision/rotate response. `secret_key_b64` is the 64-byte
119/// PASETO V4 `seed || public` layout, base64url-no-pad encoded — write it
120/// to the consumer's config dir before this response leaves the operator's
121/// hands. Cheers retains nothing of the secret half.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ProvisionResponse {
124    pub principal: Principal,
125    pub signing_key: SigningKey,
126    pub secret_key_b64: String,
127}
128
129impl ProvisionResponse {
130    fn from_provisioned(p: ProvisionedKey) -> Self {
131        Self {
132            principal: p.principal,
133            signing_key: p.signing_key,
134            secret_key_b64: URL_SAFE_NO_PAD.encode(p.secret_key),
135        }
136    }
137}
138
139/// Mount `POST /admin/service-principals` + `POST /admin/service-principals/{id}/rotate`.
140/// The product nests this under whatever base path it chose (e.g. `/api`).
141pub fn router<V, Rd, S>(state: Arc<AdminAuthState<V, Rd, S>>) -> Router
142where
143    V: TokenVerifier + Send + Sync + 'static,
144    Rd: RevocationReader + Send + Sync + 'static,
145    S: ServicePrincipalStore + 'static,
146{
147    Router::new()
148        .route(
149            "/admin/service-principals",
150            post(create_service_principal::<V, Rd, S>),
151        )
152        .route(
153            "/admin/service-principals/{id}/rotate",
154            post(rotate_service_principal::<V, Rd, S>),
155        )
156        .with_state(state)
157}
158
159/// `POST /admin/service-principals` — allocate a fresh service principal and
160/// return its Ed25519 keypair (secret returned **once**).
161pub async fn create_service_principal<V, Rd, S>(
162    State(state): State<Arc<AdminAuthState<V, Rd, S>>>,
163    headers: HeaderMap,
164    Json(body): Json<CreateServicePrincipalBody>,
165) -> Result<(StatusCode, Json<ProvisionResponse>), RouteError>
166where
167    V: TokenVerifier + Send + Sync,
168    Rd: RevocationReader,
169    S: ServicePrincipalStore,
170{
171    let now = now_unix();
172    let claims = authenticate(&headers, &state.edge, now).await?;
173    if !state.operators.is_operator(&claims.sub) {
174        return Err(RouteError::NotOperator);
175    }
176    let provisioned = state
177        .authority
178        .provision(NewServicePrincipal::new(body.desired_id), now)
179        .await?;
180    Ok((
181        StatusCode::CREATED,
182        Json(ProvisionResponse::from_provisioned(provisioned)),
183    ))
184}
185
186/// `POST /admin/service-principals/{id}/rotate` — register a fresh keypair
187/// for an existing service principal, retire the previously-active key into
188/// the JWKS overlap window, and return the new secret **once**.
189pub async fn rotate_service_principal<V, Rd, S>(
190    State(state): State<Arc<AdminAuthState<V, Rd, S>>>,
191    headers: HeaderMap,
192    Path(id): Path<String>,
193) -> Result<Json<ProvisionResponse>, RouteError>
194where
195    V: TokenVerifier + Send + Sync,
196    Rd: RevocationReader,
197    S: ServicePrincipalStore,
198{
199    let now = now_unix();
200    let claims = authenticate(&headers, &state.edge, now).await?;
201    if !state.operators.is_operator(&claims.sub) {
202        return Err(RouteError::NotOperator);
203    }
204    let target = PrincipalId::service(id);
205    let provisioned = state.authority.rotate(&target, now).await?;
206    Ok(Json(ProvisionResponse::from_provisioned(provisioned)))
207}
208
209fn now_unix() -> i64 {
210    use std::time::{SystemTime, UNIX_EPOCH};
211    SystemTime::now()
212        .duration_since(UNIX_EPOCH)
213        .map(|d| d.as_secs() as i64)
214        .unwrap_or_default()
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    use std::collections::HashSet;
222
223    use axum::body::{Body, to_bytes};
224    use axum::http::{Request, StatusCode, header};
225    use cheers_core::{Claims, DeviceBinding, DeviceId, PrincipalId, TokenMinter, UserId};
226    use cheers_server::{
227        EdgeVerifier, MemoryServicePrincipalStore, PasetoV4SecretMinter, RevocationReader,
228        ServicePrincipalAuthority, SigningKeyStatus,
229    };
230    use tower::ServiceExt;
231
232    /// In-memory `RevocationReader` — every jti reads as unrevoked. Tests
233    /// for revocation interaction live in `me::tests`; here we only care that
234    /// the session bearer verifies.
235    #[derive(Default, Clone, Debug)]
236    struct NoopRevoked;
237
238    #[async_trait::async_trait]
239    impl RevocationReader for NoopRevoked {
240        async fn is_revoked(&self, _jti: &str) -> Result<bool, cheers_core::StoreError> {
241            Ok(false)
242        }
243
244        async fn is_device_revoked(&self, _device: &DeviceId, _seq: u64) -> Result<bool, cheers_core::StoreError> {
245            Ok(false)
246        }
247
248        async fn is_membership_revoked(
249            &self,
250            _key: &cheers_core::RevocationKey,
251            _kind: &str,
252            _id: &str,
253            _principal: &cheers_core::PrincipalId,
254            _snapshot_epoch: u64,
255        ) -> Result<bool, cheers_core::StoreError> {
256            Ok(false)
257        }
258    }
259
260    /// Static allow-list `OperatorPolicy` for tests. A real product wires
261    /// whatever shape it likes — the trait is the contract.
262    #[derive(Default, Clone, Debug)]
263    struct AllowList(HashSet<UserId>);
264
265    impl AllowList {
266        fn with(ids: &[&str]) -> Self {
267            Self(ids.iter().map(|s| UserId::new(*s)).collect())
268        }
269    }
270
271    impl OperatorPolicy for AllowList {
272        fn is_operator(&self, user: &UserId) -> bool {
273            self.0.contains(user)
274        }
275    }
276
277    fn now() -> i64 {
278        use std::time::{SystemTime, UNIX_EPOCH};
279        SystemTime::now()
280            .duration_since(UNIX_EPOCH)
281            .map(|d| d.as_secs() as i64)
282            .expect("clock past epoch")
283    }
284
285    /// Build the admin router with an empty in-memory principal store and a
286    /// configurable operator allow-list. Returns the router plus the bits
287    /// tests need to mint session bearers and observe authority state.
288    fn rig(
289        operators: &[&str],
290    ) -> (
291        Router,
292        PasetoV4SecretMinter,
293        Arc<ServicePrincipalAuthority<MemoryServicePrincipalStore>>,
294    ) {
295        let (minter, verifier) = PasetoV4SecretMinter::generate().expect("paseto v4 keypair");
296        let edge = Arc::new(EdgeVerifier::new(verifier, NoopRevoked));
297        let authority = Arc::new(ServicePrincipalAuthority::new(
298            MemoryServicePrincipalStore::new(),
299        ));
300        let state = Arc::new(AdminAuthState {
301            edge,
302            authority: authority.clone(),
303            operators: Arc::new(AllowList::with(operators)),
304        });
305        let app = Router::new().nest("/api", router(state));
306        (app, minter, authority)
307    }
308
309    /// Mint a session token (the `Claims` shape — NOT McpClaims). The admin
310    /// endpoints intentionally take session bearers; this matches what an
311    /// operator-passkey-authenticated browser would carry.
312    fn mint_session_for(minter: &PasetoV4SecretMinter, user: &str, now: i64) -> String {
313        let claims = Claims::new(
314            UserId::new(user),
315            DeviceId::new("admin-laptop"),
316            DeviceBinding::Passkey,
317            now,
318            now + 600,
319        );
320        minter.mint(&claims).expect("session mint")
321    }
322
323    fn bearer(token: &str) -> (header::HeaderName, String) {
324        (header::AUTHORIZATION, format!("Bearer {token}"))
325    }
326
327    async fn body_json<T: for<'de> serde::Deserialize<'de>>(body: Body) -> T {
328        let bytes = to_bytes(body, 16 * 1024).await.expect("body bytes");
329        serde_json::from_slice(&bytes).expect("json decode")
330    }
331
332    // ---- create_service_principal -----------------------------------------
333
334    #[tokio::test]
335    async fn create_service_principal_returns_secret_exactly_once() {
336        let (app, minter, authority) = rig(&["alice"]);
337        let token = mint_session_for(&minter, "alice", now());
338        let (k, v) = bearer(&token);
339        let req = Request::builder()
340            .method("POST")
341            .uri("/api/admin/service-principals")
342            .header(k, v)
343            .header(header::CONTENT_TYPE, "application/json")
344            .body(Body::from(r#"{"desired_id":"yubaba-1"}"#))
345            .unwrap();
346        let resp = app.oneshot(req).await.unwrap();
347        assert_eq!(resp.status(), StatusCode::CREATED);
348        let body: ProvisionResponse = body_json(resp.into_body()).await;
349        assert_eq!(body.principal.id, PrincipalId::service("yubaba-1"));
350        assert_eq!(body.signing_key.status, SigningKeyStatus::Active);
351        // 64-byte secret base64url-no-pad (88 chars without padding).
352        let raw = URL_SAFE_NO_PAD
353            .decode(body.secret_key_b64.as_bytes())
354            .expect("secret decodes");
355        assert_eq!(raw.len(), 64);
356        // Cheers retained the pubkey; the published JWKS set sees it.
357        let live = authority.published_signing_keys(now()).await.unwrap();
358        assert_eq!(live.len(), 1);
359        assert_eq!(live[0].kid, body.signing_key.kid);
360    }
361
362    #[tokio::test]
363    async fn create_service_principal_rejects_non_operator_with_403() {
364        let (app, minter, authority) = rig(&["alice"]); // bob is NOT an operator
365        let token = mint_session_for(&minter, "bob", now());
366        let (k, v) = bearer(&token);
367        let req = Request::builder()
368            .method("POST")
369            .uri("/api/admin/service-principals")
370            .header(k, v)
371            .header(header::CONTENT_TYPE, "application/json")
372            .body(Body::from(r#"{"desired_id":"yubaba-2"}"#))
373            .unwrap();
374        let resp = app.oneshot(req).await.unwrap();
375        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
376        // Critically: no principal was allocated — the authority store is
377        // unchanged. (We assert via the empty published JWKS, which is the
378        // observable surface; the store has no read-by-id from outside.)
379        let live = authority.published_signing_keys(now()).await.unwrap();
380        assert!(live.is_empty(), "non-operator must not provision: {live:?}");
381    }
382
383    #[tokio::test]
384    async fn create_service_principal_rejects_missing_bearer_with_401() {
385        let (app, _minter, _) = rig(&["alice"]);
386        let req = Request::builder()
387            .method("POST")
388            .uri("/api/admin/service-principals")
389            .header(header::CONTENT_TYPE, "application/json")
390            .body(Body::from(r#"{"desired_id":"yubaba-3"}"#))
391            .unwrap();
392        let resp = app.oneshot(req).await.unwrap();
393        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
394    }
395
396    #[tokio::test]
397    async fn create_service_principal_rejects_duplicate_id_with_409() {
398        let (app, minter, _) = rig(&["alice"]);
399        let token = mint_session_for(&minter, "alice", now());
400        let make_req = || {
401            let (k, v) = bearer(&token);
402            Request::builder()
403                .method("POST")
404                .uri("/api/admin/service-principals")
405                .header(k, v)
406                .header(header::CONTENT_TYPE, "application/json")
407                .body(Body::from(r#"{"desired_id":"yubaba-dup"}"#))
408                .unwrap()
409        };
410        let resp = app.clone().oneshot(make_req()).await.unwrap();
411        assert_eq!(resp.status(), StatusCode::CREATED);
412        let resp = app.oneshot(make_req()).await.unwrap();
413        assert_eq!(resp.status(), StatusCode::CONFLICT);
414    }
415
416    // ---- rotate_service_principal -----------------------------------------
417
418    #[tokio::test]
419    async fn rotate_service_principal_retires_old_and_issues_fresh() {
420        let (app, minter, authority) = rig(&["alice"]);
421        let token = mint_session_for(&minter, "alice", now());
422
423        // Provision first.
424        let (k, v) = bearer(&token);
425        let req = Request::builder()
426            .method("POST")
427            .uri("/api/admin/service-principals")
428            .header(k, v)
429            .header(header::CONTENT_TYPE, "application/json")
430            .body(Body::from(r#"{"desired_id":"yubaba-r"}"#))
431            .unwrap();
432        let resp = app.clone().oneshot(req).await.unwrap();
433        assert_eq!(resp.status(), StatusCode::CREATED);
434        let first: ProvisionResponse = body_json(resp.into_body()).await;
435
436        // Then rotate.
437        let (k, v) = bearer(&token);
438        let req = Request::builder()
439            .method("POST")
440            .uri("/api/admin/service-principals/yubaba-r/rotate")
441            .header(k, v)
442            .body(Body::empty())
443            .unwrap();
444        let resp = app.oneshot(req).await.unwrap();
445        assert_eq!(resp.status(), StatusCode::OK);
446        let second: ProvisionResponse = body_json(resp.into_body()).await;
447
448        // Fresh kid, fresh pubkey, different secret.
449        assert_ne!(second.signing_key.kid, first.signing_key.kid);
450        assert_ne!(
451            second.signing_key.public_key,
452            first.signing_key.public_key
453        );
454        assert_ne!(second.secret_key_b64, first.secret_key_b64);
455
456        // Both kids live in the published JWKS during the overlap window.
457        let live = authority.published_signing_keys(now()).await.unwrap();
458        let mut kids: Vec<_> = live.iter().map(|k| k.kid.clone()).collect();
459        kids.sort();
460        let mut expected = vec![first.signing_key.kid, second.signing_key.kid];
461        expected.sort();
462        assert_eq!(kids, expected);
463    }
464
465    #[tokio::test]
466    async fn rotate_service_principal_unknown_id_returns_404() {
467        let (app, minter, _) = rig(&["alice"]);
468        let token = mint_session_for(&minter, "alice", now());
469        let (k, v) = bearer(&token);
470        let req = Request::builder()
471            .method("POST")
472            .uri("/api/admin/service-principals/ghost/rotate")
473            .header(k, v)
474            .body(Body::empty())
475            .unwrap();
476        let resp = app.oneshot(req).await.unwrap();
477        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
478    }
479
480    #[tokio::test]
481    async fn rotate_service_principal_rejects_non_operator_with_403() {
482        let (app, minter, _) = rig(&["alice"]);
483        // bob isn't on the list — should be 403 before the rotation runs.
484        let token = mint_session_for(&minter, "bob", now());
485        let (k, v) = bearer(&token);
486        let req = Request::builder()
487            .method("POST")
488            .uri("/api/admin/service-principals/yubaba-x/rotate")
489            .header(k, v)
490            .body(Body::empty())
491            .unwrap();
492        let resp = app.oneshot(req).await.unwrap();
493        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
494    }
495
496    // ---- response shape ---------------------------------------------------
497
498    #[tokio::test]
499    async fn provision_response_serializes_secret_as_base64url_no_pad() {
500        // Exercise the secret-encoding path via a real provision (SigningKey
501        // and ProvisionedKey are #[non_exhaustive] — they can only be
502        // constructed from inside cheers-server, which is what the authority
503        // does).
504        let authority = ServicePrincipalAuthority::new(MemoryServicePrincipalStore::new());
505        let provisioned = authority
506            .provision(NewServicePrincipal::new("yubaba-enc"), 1_000)
507            .await
508            .unwrap();
509        let response = ProvisionResponse::from_provisioned(provisioned);
510        let json = serde_json::to_string(&response).unwrap();
511        assert!(json.contains("\"secret_key_b64\""));
512        // 64 raw bytes → 86-char base64url without padding (88 with padding).
513        assert_eq!(response.secret_key_b64.len(), 86);
514        assert!(!response.secret_key_b64.contains('='), "{response:?}");
515        // The encoded form round-trips back to exactly 64 bytes.
516        let raw = URL_SAFE_NO_PAD
517            .decode(response.secret_key_b64.as_bytes())
518            .unwrap();
519        assert_eq!(raw.len(), 64);
520    }
521
522    // ---- trait dyn-compat -------------------------------------------------
523
524    #[test]
525    fn operator_policy_is_dyn_compatible() {
526        fn _f(_: &dyn OperatorPolicy) {}
527    }
528}