use std::sync::Arc;
use cheers_core::yah_scopes;
use axum::Json;
use axum::Router;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::routing::post;
use serde::{Deserialize, Serialize};
use cheers_core::{Principal, PrincipalId, UserDelegation};
use cheers_server::{
CampAuthority, CampBootstrapCredential, CampPrincipalStore, NewCampPrincipal,
UserSigningKeyStore,
};
use crate::error::RouteError;
use crate::mcp::{McpAuthState, McpClaimsExt, authenticate_mcp};
pub struct CampAdminState<S, K> {
pub mcp: Arc<McpAuthState>,
pub authority: Arc<CampAuthority<S, K>>,
}
impl<S, K> std::fmt::Debug for CampAdminState<S, K> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CampAdminState").finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct CreateCampBootstrapBody {
pub bound_to: PrincipalId,
pub desired_id: String,
pub delegation: UserDelegation,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CampBootstrapResponse {
pub principal: Principal,
pub credential: CampBootstrapCredential,
}
impl CampBootstrapResponse {
fn from_provisioned(p: cheers_server::ProvisionedCamp) -> Self {
Self {
principal: p.principal,
credential: p.credential,
}
}
}
pub fn router<S, K>(state: Arc<CampAdminState<S, K>>) -> Router
where
S: CampPrincipalStore + 'static,
K: UserSigningKeyStore + 'static,
{
Router::new()
.route("/admin/camps/bootstrap", post(bootstrap_camp::<S, K>))
.with_state(state)
}
pub async fn bootstrap_camp<S, K>(
State(state): State<Arc<CampAdminState<S, K>>>,
headers: HeaderMap,
Json(body): Json<CreateCampBootstrapBody>,
) -> Result<(StatusCode, Json<CampBootstrapResponse>), RouteError>
where
S: CampPrincipalStore,
K: UserSigningKeyStore,
{
let now = now_unix();
let claims = authenticate_mcp(&headers, &state.mcp, now).await?;
claims.require_scope(yah_scopes::CAMP_ADMIN)?;
let provisioned = state
.authority
.provision(
NewCampPrincipal::new(body.bound_to, body.desired_id),
body.delegation,
now,
)
.await?;
Ok((
StatusCode::CREATED,
Json(CampBootstrapResponse::from_provisioned(provisioned)),
))
}
fn now_unix() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode, header};
use cheers_core::{
Actor, AuthStrength, McpClaims, PrincipalId, PrincipalKind, PrincipalStatus,
UserDelegation,
};
use cheers_server::{
CampAuthority, MemoryCampPrincipalStore, MemoryUserSigningKeyStore,
PasetoV4SecretMinter, UserSigningKey, UserSigningKeyStatus,
};
use ed25519_compact::{KeyPair, Seed};
use tower::ServiceExt;
fn now() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.expect("clock past epoch")
}
fn keypair_from_seed(seed: u8) -> KeyPair {
KeyPair::from_seed(Seed::from_slice(&[seed; 32]).unwrap())
}
fn signed_delegation(
kp: &KeyPair,
bound_to: PrincipalId,
camp_id: &str,
issued_at: i64,
expires_at: i64,
) -> UserDelegation {
let unsigned = UserDelegation::new(
bound_to,
camp_id,
issued_at,
expires_at,
*kp.pk,
[0u8; 64],
)
.unwrap();
let sig = kp.sk.sign(&unsigned.signing_payload(), None);
let mut bytes = [0u8; 64];
bytes.copy_from_slice(sig.as_ref());
UserDelegation::new(
unsigned.bound_to,
unsigned.camp_id,
unsigned.issued_at,
unsigned.expires_at,
unsigned.user_signing_key,
bytes,
)
.unwrap()
}
fn trust_key(store: &MemoryUserSigningKeyStore, user: &PrincipalId, pubkey: [u8; 32]) {
store.insert(UserSigningKey::new(
"trusted",
user.clone(),
pubkey,
UserSigningKeyStatus::Active,
0,
));
}
const CAMPS_TEST_KID: &str = "camps-test-kid-1";
#[allow(clippy::type_complexity)]
fn rig() -> (
Router,
PasetoV4SecretMinter,
Arc<CampAuthority<MemoryCampPrincipalStore, MemoryUserSigningKeyStore>>,
MemoryUserSigningKeyStore,
MemoryCampPrincipalStore,
) {
let (minter, verifier) = PasetoV4SecretMinter::generate().expect("paseto v4 keypair");
let mcp = Arc::new(McpAuthState::new(
verifier,
CAMPS_TEST_KID,
"https://cheers.example",
"https://cheers.example/api",
));
let camp_store = MemoryCampPrincipalStore::new();
let key_store = MemoryUserSigningKeyStore::new();
let authority = Arc::new(CampAuthority::new(camp_store.clone(), key_store.clone()));
let state = Arc::new(CampAdminState {
mcp,
authority: authority.clone(),
});
let app = Router::new().nest("/api", router(state));
(app, minter, authority, key_store, camp_store)
}
fn mint_warden_mcp(
minter: &PasetoV4SecretMinter,
scopes: Vec<cheers_core::Scope>,
now_s: i64,
) -> String {
let claims = McpClaims::new(
"https://cheers.example",
"https://cheers.example/api",
PrincipalId::service("yubaba-1"),
now_s,
now_s + 600,
"jti-yubaba",
scopes,
)
.with_act(Actor::new(PrincipalId::service("yubaba-1")))
.with_auth_strength(AuthStrength::Bootstrap);
minter.mint_mcp(&claims, CAMPS_TEST_KID).expect("mcp mint")
}
fn bearer(token: &str) -> (header::HeaderName, String) {
(header::AUTHORIZATION, format!("Bearer {token}"))
}
async fn body_json<T: for<'de> serde::Deserialize<'de>>(body: Body) -> T {
let bytes = to_bytes(body, 16 * 1024).await.expect("body bytes");
serde_json::from_slice(&bytes).expect("json decode")
}
fn json_body(body: &CreateCampBootstrapBody) -> Body {
Body::from(serde_json::to_string(body).expect("body json"))
}
impl Serialize for CreateCampBootstrapBody {
fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
let mut s = ser.serialize_struct("CreateCampBootstrapBody", 3)?;
s.serialize_field("bound_to", &self.bound_to)?;
s.serialize_field("desired_id", &self.desired_id)?;
s.serialize_field("delegation", &self.delegation)?;
s.end()
}
}
#[tokio::test]
async fn bootstrap_camp_happy_path_returns_201_with_camp_principal_and_credential() {
let (app, minter, authority, keys, _store) = rig();
let user = PrincipalId::user("alice");
let kp = keypair_from_seed(1);
trust_key(&keys, &user, *kp.pk);
let now_s = now();
let delegation = signed_delegation(
&kp,
user.clone(),
"camp-xyz",
now_s,
now_s + 600,
);
let token = mint_warden_mcp(&minter, vec![yah_scopes::CAMP_ADMIN], now_s);
let body = CreateCampBootstrapBody {
bound_to: user.clone(),
desired_id: "camp-xyz".into(),
delegation,
};
let (k, v) = bearer(&token);
let req = Request::builder()
.method("POST")
.uri("/api/admin/camps/bootstrap")
.header(k, v)
.header(header::CONTENT_TYPE, "application/json")
.body(json_body(&body))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let payload: CampBootstrapResponse = body_json(resp.into_body()).await;
assert_eq!(payload.principal.id, PrincipalId::camp("camp-xyz"));
assert_eq!(payload.principal.bound_to.as_ref(), Some(&user));
assert_eq!(payload.principal.status, PrincipalStatus::Active);
assert_eq!(payload.credential.camp_id, PrincipalId::camp("camp-xyz"));
assert!(!payload.credential.token.is_empty());
assert!(!payload.credential.revoked);
let camp_id = payload.principal.id.clone();
let stored = authority
.store()
.get_principal(&camp_id)
.await
.unwrap()
.unwrap();
assert_eq!(stored, payload.principal);
}
#[tokio::test]
async fn bootstrap_credential_round_trips_into_mint_bootstrap_path() {
use cheers_server::{
McpAuthority, MemoryBundleStore, SchemaGrantStore,
};
use cheers_server::{MemoryOwnershipStore, NewOwnership, OwnershipStore};
let (app, _verifier_minter, camp_authority, keys, _store) = rig();
let user = PrincipalId::user("alice");
let kp = keypair_from_seed(50);
trust_key(&keys, &user, *kp.pk);
let now_s = now();
let d = signed_delegation(&kp, user.clone(), "camp-y", now_s, now_s + 600);
let prov = camp_authority
.provision(
NewCampPrincipal::new(user.clone(), "camp-y"),
d,
now_s,
)
.await
.unwrap();
const MINT_AUTHORITY_KID: &str = "camp-y-mint-authority-kid";
let (mcp_minter, mcp_verifier) = PasetoV4SecretMinter::generate().unwrap();
const GRANT: cheers_core::ResourceSchema = cheers_core::ResourceSchema {
kind: "grant",
relations: &[],
kind_relations: &[cheers_core::RelationDef {
name: "cloud:read",
membership: true,
implies: &[],
scopes: &[yah_scopes::CLOUD_READ],
grants: &[],
}],
};
let ownership = Arc::new(MemoryOwnershipStore::new());
let bundles = MemoryBundleStore::with_defaults();
let aud = "https://kamaji.camp.example";
let scopes = Arc::new(yah_scopes::registry_at([aud]).unwrap());
let schema = cheers_core::SchemaRegistry::build(&[GRANT], &scopes).unwrap();
ownership
.insert(
&NewOwnership::new(
prov.principal.id.clone(),
cheers_core::KIND_RESOURCE,
"grant",
"cloud:read",
PrincipalId::service("seed"),
None,
)
.unwrap(),
now_s,
)
.await
.unwrap();
let grants = SchemaGrantStore::new(ownership.clone(), Arc::new(schema), scopes.clone());
let authority = McpAuthority::new(
mcp_minter,
bundles,
grants,
ownership,
scopes,
"https://cheers.example",
MINT_AUTHORITY_KID,
);
let cred = camp_authority
.store()
.get_credential(&prov.credential.token)
.await
.unwrap()
.expect("credential persisted");
let minted = authority
.mint_bootstrap(cred.camp_id, aud, now_s)
.await
.unwrap();
assert!(minted.token.starts_with("v4.public."));
assert_eq!(minted.claims.sub, prov.principal.id);
assert_eq!(minted.claims.camp_id.as_deref(), Some("camp-y"));
let back = mcp_verifier
.verify_mcp_at(&minted.token, now_s + 60, MINT_AUTHORITY_KID)
.unwrap();
assert_eq!(back, minted.claims);
assert_eq!(prov.principal.status, PrincipalStatus::Active);
drop(app);
}
#[tokio::test]
async fn bootstrap_camp_rejects_missing_bearer_with_401() {
let (app, _minter, _, keys, _) = rig();
let user = PrincipalId::user("alice");
let kp = keypair_from_seed(2);
trust_key(&keys, &user, *kp.pk);
let now_s = now();
let d = signed_delegation(&kp, user.clone(), "c", now_s, now_s + 600);
let body = CreateCampBootstrapBody {
bound_to: user,
desired_id: "c".into(),
delegation: d,
};
let req = Request::builder()
.method("POST")
.uri("/api/admin/camps/bootstrap")
.header(header::CONTENT_TYPE, "application/json")
.body(json_body(&body))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn bootstrap_camp_rejects_session_bearer_as_unauthorized() {
use cheers_core::{Claims, DeviceBinding, DeviceId, TokenMinter, UserId};
let (app, minter, _, keys, _) = rig();
let user = PrincipalId::user("alice");
let kp = keypair_from_seed(3);
trust_key(&keys, &user, *kp.pk);
let now_s = now();
let d = signed_delegation(&kp, user.clone(), "c", now_s, now_s + 600);
let session = Claims::new(
UserId::new("yubaba-operator"),
DeviceId::new("dev"),
DeviceBinding::Passkey,
now_s,
now_s + 600,
);
let token = minter.mint(&session).unwrap();
let body = CreateCampBootstrapBody {
bound_to: user,
desired_id: "c".into(),
delegation: d,
};
let (k, v) = bearer(&token);
let req = Request::builder()
.method("POST")
.uri("/api/admin/camps/bootstrap")
.header(k, v)
.header(header::CONTENT_TYPE, "application/json")
.body(json_body(&body))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn bootstrap_camp_rejects_missing_camp_admin_scope_with_403() {
let (app, minter, _, keys, store) = rig();
let user = PrincipalId::user("alice");
let kp = keypair_from_seed(4);
trust_key(&keys, &user, *kp.pk);
let now_s = now();
let d = signed_delegation(&kp, user.clone(), "c", now_s, now_s + 600);
let token = mint_warden_mcp(&minter, vec![yah_scopes::CLOUD_READ], now_s);
let body = CreateCampBootstrapBody {
bound_to: user,
desired_id: "c".into(),
delegation: d,
};
let (k, v) = bearer(&token);
let req = Request::builder()
.method("POST")
.uri("/api/admin/camps/bootstrap")
.header(k, v)
.header(header::CONTENT_TYPE, "application/json")
.body(json_body(&body))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let p = store
.get_principal(&PrincipalId::camp("c"))
.await
.unwrap();
assert!(p.is_none(), "scope failure must not allocate: {p:?}");
}
#[tokio::test]
async fn bootstrap_camp_rejects_expired_delegation_with_400() {
let (app, minter, _, keys, _) = rig();
let user = PrincipalId::user("alice");
let kp = keypair_from_seed(5);
trust_key(&keys, &user, *kp.pk);
let now_s = now();
let d = signed_delegation(&kp, user.clone(), "c", now_s - 200, now_s - 100);
let token = mint_warden_mcp(&minter, vec![yah_scopes::CAMP_ADMIN], now_s);
let body = CreateCampBootstrapBody {
bound_to: user,
desired_id: "c".into(),
delegation: d,
};
let (k, v) = bearer(&token);
let req = Request::builder()
.method("POST")
.uri("/api/admin/camps/bootstrap")
.header(k, v)
.header(header::CONTENT_TYPE, "application/json")
.body(json_body(&body))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn bootstrap_camp_rejects_untrusted_signing_key_with_401() {
let (app, minter, _, _keys, _) = rig();
let user = PrincipalId::user("alice");
let kp = keypair_from_seed(6);
let now_s = now();
let d = signed_delegation(&kp, user.clone(), "c", now_s, now_s + 600);
let token = mint_warden_mcp(&minter, vec![yah_scopes::CAMP_ADMIN], now_s);
let body = CreateCampBootstrapBody {
bound_to: user,
desired_id: "c".into(),
delegation: d,
};
let (k, v) = bearer(&token);
let req = Request::builder()
.method("POST")
.uri("/api/admin/camps/bootstrap")
.header(k, v)
.header(header::CONTENT_TYPE, "application/json")
.body(json_body(&body))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn bootstrap_camp_rejects_bad_signature_with_401() {
let (app, minter, _, keys, _) = rig();
let user = PrincipalId::user("alice");
let kp = keypair_from_seed(7);
trust_key(&keys, &user, *kp.pk);
let now_s = now();
let mut d = signed_delegation(&kp, user.clone(), "c", now_s, now_s + 600);
d.signature[0] ^= 0x01;
let token = mint_warden_mcp(&minter, vec![yah_scopes::CAMP_ADMIN], now_s);
let body = CreateCampBootstrapBody {
bound_to: user,
desired_id: "c".into(),
delegation: d,
};
let (k, v) = bearer(&token);
let req = Request::builder()
.method("POST")
.uri("/api/admin/camps/bootstrap")
.header(k, v)
.header(header::CONTENT_TYPE, "application/json")
.body(json_body(&body))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn bootstrap_camp_rejects_delegation_bound_to_mismatch_with_400() {
let (app, minter, _, keys, _) = rig();
let user_a = PrincipalId::user("alice");
let user_b = PrincipalId::user("bob");
let kp_b = keypair_from_seed(8);
trust_key(&keys, &user_a, *kp_b.pk);
trust_key(&keys, &user_b, *kp_b.pk);
let now_s = now();
let d = signed_delegation(&kp_b, user_b.clone(), "c", now_s, now_s + 600);
let token = mint_warden_mcp(&minter, vec![yah_scopes::CAMP_ADMIN], now_s);
let body = CreateCampBootstrapBody {
bound_to: user_a,
desired_id: "c".into(),
delegation: d,
};
let (k, v) = bearer(&token);
let req = Request::builder()
.method("POST")
.uri("/api/admin/camps/bootstrap")
.header(k, v)
.header(header::CONTENT_TYPE, "application/json")
.body(json_body(&body))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn bootstrap_camp_rejects_duplicate_camp_id_with_409() {
let (app, minter, _, keys, _) = rig();
let user = PrincipalId::user("alice");
let kp = keypair_from_seed(9);
trust_key(&keys, &user, *kp.pk);
let now_s = now();
let d1 = signed_delegation(&kp, user.clone(), "dup", now_s, now_s + 600);
let d2 = signed_delegation(&kp, user.clone(), "dup", now_s + 10, now_s + 700);
let token = mint_warden_mcp(&minter, vec![yah_scopes::CAMP_ADMIN], now_s);
let make_req = |d: UserDelegation, t: &str| {
let body = CreateCampBootstrapBody {
bound_to: user.clone(),
desired_id: "dup".into(),
delegation: d,
};
let (k, v) = bearer(t);
Request::builder()
.method("POST")
.uri("/api/admin/camps/bootstrap")
.header(k, v)
.header(header::CONTENT_TYPE, "application/json")
.body(json_body(&body))
.unwrap()
};
let resp = app.clone().oneshot(make_req(d1, &token)).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let resp = app.oneshot(make_req(d2, &token)).await.unwrap();
assert_eq!(resp.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn bootstrap_camp_rejects_unprefixed_bound_to_at_parse_time() {
let (app, minter, _, _, _) = rig();
let now_s = now();
let token = mint_warden_mcp(&minter, vec![yah_scopes::CAMP_ADMIN], now_s);
let raw_body = format!(
r#"{{
"bound_to":"alice",
"desired_id":"c",
"delegation": {{
"bound_to":"user:alice",
"camp_id":"c",
"issued_at":{0},
"expires_at":{1},
"user_signing_key":"{2}",
"signature":"{2}{2}"
}}
}}"#,
now_s,
now_s + 600,
"A".repeat(32),
);
let (k, v) = bearer(&token);
let req = Request::builder()
.method("POST")
.uri("/api/admin/camps/bootstrap")
.header(k, v)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(raw_body))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert!(
resp.status().is_client_error(),
"expected 4xx, got {}",
resp.status()
);
}
#[tokio::test]
async fn bootstrap_response_serializes_principal_and_credential() {
let (app, minter, _, keys, _) = rig();
let user = PrincipalId::user("alice");
let kp = keypair_from_seed(10);
trust_key(&keys, &user, *kp.pk);
let now_s = now();
let d = signed_delegation(&kp, user.clone(), "c-encoded", now_s, now_s + 600);
let token = mint_warden_mcp(&minter, vec![yah_scopes::CAMP_ADMIN], now_s);
let body = CreateCampBootstrapBody {
bound_to: user.clone(),
desired_id: "c-encoded".into(),
delegation: d,
};
let (k, v) = bearer(&token);
let req = Request::builder()
.method("POST")
.uri("/api/admin/camps/bootstrap")
.header(k, v)
.header(header::CONTENT_TYPE, "application/json")
.body(json_body(&body))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let bytes = to_bytes(resp.into_body(), 16 * 1024).await.unwrap();
let json = std::str::from_utf8(&bytes).unwrap();
assert!(json.contains("\"id\":\"camp:c-encoded\""), "{json}");
assert!(json.contains("\"bound_to\":\"user:alice\""), "{json}");
assert!(json.contains("\"token\":\""), "{json}");
let payload: CampBootstrapResponse = serde_json::from_slice(&bytes).unwrap();
assert_eq!(payload.principal.kind(), PrincipalKind::Camp);
assert!(!payload.credential.token.is_empty());
}
}