1use 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
80pub trait OperatorPolicy: Send + Sync {
85 fn is_operator(&self, user: &UserId) -> bool;
86}
87
88pub 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#[derive(Debug, Clone, Deserialize)]
114pub struct CreateServicePrincipalBody {
115 pub desired_id: String,
116}
117
118#[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
139pub 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
159pub 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
186pub 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 #[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 #[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 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 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 #[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 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 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"]); 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 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 #[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 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 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 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 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 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 #[tokio::test]
499 async fn provision_response_serializes_secret_as_base64url_no_pad() {
500 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 assert_eq!(response.secret_key_b64.len(), 86);
514 assert!(!response.secret_key_b64.contains('='), "{response:?}");
515 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 #[test]
525 fn operator_policy_is_dyn_compatible() {
526 fn _f(_: &dyn OperatorPolicy) {}
527 }
528}