use super::*;
#[derive(Deserialize)]
pub(super) struct CreateTokenRequest {
label: String,
#[serde(default)]
roles: Vec<String>,
#[serde(default)]
ttl_secs: Option<u64>,
#[serde(default)]
holder_pubkey: Option<String>,
}
#[derive(Serialize)]
struct CreateTokenResponse {
token: String,
id: String,
}
pub(super) async fn create_token(
State(deploy): State<DeployStore>,
Extension(issuer): Extension<Issuer>,
Json(request): Json<CreateTokenRequest>,
) -> Response {
let Some(signer) = issuer.0 else {
return (
StatusCode::NOT_IMPLEMENTED,
"this node has no root private key and cannot issue tokens\n",
)
.into_response();
};
let roles: Vec<GrantedRole> = request
.roles
.iter()
.map(|s| GrantedRole::parse(s))
.collect();
if roles.is_empty() {
return (StatusCode::BAD_REQUEST, "at least one role is required\n").into_response();
}
let now = now_unix();
let claims = Claims {
roles: roles.clone(),
kind: cose::KIND_ROLE.to_string(),
ttl_secs: request.ttl_secs,
now_unix: now,
};
let holder = match &request.holder_pubkey {
Some(hex) => match cose::TokenPublicKey::from_hex(hex) {
Ok(pk) => Some(pk),
Err(err) => {
return (
StatusCode::BAD_REQUEST,
format!("invalid holder key: {err}\n"),
)
.into_response()
}
},
None => None,
};
let minted = match &holder {
Some(holder) => cose::mint_delegatable(&claims, holder, &*signer).await,
None => cose::mint(&claims, &*signer).await,
};
let token = match minted {
Ok(t) => t,
Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
};
let id = match cose::verify(&token, &signer.public_key(), now) {
Ok(v) => v.cti,
Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
};
let meta = TokenMeta {
version: boatramp_core::SCHEMA_VERSION,
label: request.label,
roles,
created_at: now,
expires_at: request.ttl_secs.map(|t| now.saturating_add(t)),
revocation_id: id.clone(),
};
match deploy.put_token_meta(&meta).await {
Ok(()) => (StatusCode::CREATED, Json(CreateTokenResponse { token, id })).into_response(),
Err(err) => deploy_error_response(err),
}
}
#[derive(Deserialize)]
pub(super) struct BootstrapRequest {
#[serde(default)]
pub(super) roles: Vec<String>,
pub(super) ttl_secs: Option<u64>,
}
pub(super) async fn bootstrap_token(
State(deploy): State<DeployStore>,
Extension(issuer): Extension<Issuer>,
Extension(gate): Extension<BootstrapGate>,
headers: axum::http::HeaderMap,
Json(request): Json<BootstrapRequest>,
) -> Response {
let Some(inner) = gate.0 else {
return (
StatusCode::NOT_IMPLEMENTED,
"bootstrap is not enabled on this node (set a bootstrap secret)\n",
)
.into_response();
};
let Some(signer) = issuer.0 else {
return (
StatusCode::NOT_IMPLEMENTED,
"this node has no root private key and cannot issue tokens\n",
)
.into_response();
};
let presented = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.unwrap_or("");
if boatramp_core::deploy::sha256_hex(presented.as_bytes()) != inner.secret_hash {
return (StatusCode::UNAUTHORIZED, "invalid bootstrap secret\n").into_response();
}
let _guard = inner.lock.lock().await;
match deploy.bootstrap_consumed(&inner.secret_hash).await {
Ok(true) => {
return (
StatusCode::CONFLICT,
"bootstrap secret already used — rotate it to re-bootstrap\n",
)
.into_response()
}
Ok(false) => {}
Err(err) => return deploy_error_response(err),
}
let roles: Vec<GrantedRole> = if request.roles.is_empty() {
vec![GrantedRole::parse("admin")]
} else {
request
.roles
.iter()
.map(|s| GrantedRole::parse(s))
.collect()
};
let now = now_unix();
let ttl = request.ttl_secs.or(Some(3600));
let claims = Claims {
roles: roles.clone(),
kind: cose::KIND_ROLE.to_string(),
ttl_secs: ttl,
now_unix: now,
};
let token = match cose::mint(&claims, &*signer).await {
Ok(t) => t,
Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
};
let id = match cose::verify(&token, &signer.public_key(), now) {
Ok(v) => v.cti,
Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
};
let meta = TokenMeta {
version: boatramp_core::SCHEMA_VERSION,
label: "bootstrap".to_string(),
roles,
created_at: now,
expires_at: ttl.map(|t| now.saturating_add(t)),
revocation_id: id.clone(),
};
if let Err(err) = deploy.put_token_meta(&meta).await {
return deploy_error_response(err);
}
if let Err(err) = deploy.mark_bootstrap_consumed(&inner.secret_hash).await {
return deploy_error_response(err);
}
tracing::warn!(cti = %id, "control-plane bootstrapped — first token minted via bootstrap secret");
(StatusCode::CREATED, Json(CreateTokenResponse { token, id })).into_response()
}
#[derive(Deserialize)]
pub(super) struct JoinRequest {
pub(super) token: String,
pub(super) mesh_pubkey: String,
pub(super) possession_proof: String,
pub(super) proof_iat: u64,
#[serde(default)]
pub(super) advertise_addr: Option<String>,
}
#[derive(Serialize)]
struct JoinResponse {
members: Vec<String>,
#[serde(default)]
member_addrs: std::collections::BTreeMap<u64, String>,
}
pub(super) async fn cluster_join(
Extension(auth): Extension<Auth>,
Extension(mesh_control): Extension<MeshControlHandle>,
Json(request): Json<JoinRequest>,
) -> Response {
let Some(admitter) = mesh_control.0 else {
return (
StatusCode::NOT_IMPLEMENTED,
"this node is not a cluster node\n",
)
.into_response();
};
let Some(public) = auth.public_key() else {
return (
StatusCode::NOT_IMPLEMENTED,
"join requires control-plane auth (no root key configured)\n",
)
.into_response();
};
let _ = public; let now = now_unix();
let jti = match auth.verify_join_token(&request.token, now).await {
Ok(jti) => jti,
Err(err) => {
let code = match err {
cose::TokenError::Invalid(_) => StatusCode::UNAUTHORIZED,
_ => StatusCode::FORBIDDEN,
};
return (code, format!("invalid join token: {err}\n")).into_response();
}
};
let Ok(proof) = hex::decode(request.possession_proof.trim()) else {
return (StatusCode::BAD_REQUEST, "possession_proof must be hex\n").into_response();
};
match admitter
.admit(
request.mesh_pubkey.trim(),
&jti,
&proof,
request.proof_iat,
now,
request.advertise_addr.as_deref(),
)
.await
{
Ok(JoinOutcome::Admitted { members, addrs }) => (
StatusCode::OK,
Json(JoinResponse {
members,
member_addrs: addrs,
}),
)
.into_response(),
Ok(JoinOutcome::TokenSpent) => {
(StatusCode::CONFLICT, "join token already spent\n").into_response()
}
Ok(JoinOutcome::ProofInvalid) => (
StatusCode::FORBIDDEN,
"join possession proof is missing, stale, or invalid\n",
)
.into_response(),
Ok(JoinOutcome::Revoked) => (
StatusCode::FORBIDDEN,
"this mesh key is revoked; an explicit un-revoke is required before it can rejoin\n",
)
.into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("admit failed: {err}\n"),
)
.into_response(),
}
}
#[derive(Serialize)]
struct RotateKeyResponse {
pubkey: String,
}
pub(super) async fn cluster_rotate_key(
Extension(mesh_control): Extension<MeshControlHandle>,
) -> Response {
let Some(control) = mesh_control.0 else {
return (
StatusCode::NOT_IMPLEMENTED,
"this node is not a cluster node\n",
)
.into_response();
};
match control.rotate_key().await {
Ok(pubkey) => (StatusCode::OK, Json(RotateKeyResponse { pubkey })).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("rotation failed: {err}\n"),
)
.into_response(),
}
}
#[derive(Deserialize)]
pub(super) struct RevokeRequest {
node_id: u64,
}
pub(super) async fn cluster_revoke(
Extension(mesh_control): Extension<MeshControlHandle>,
Json(request): Json<RevokeRequest>,
) -> Response {
let Some(control) = mesh_control.0 else {
return (
StatusCode::NOT_IMPLEMENTED,
"this node is not a cluster node\n",
)
.into_response();
};
match control.revoke(request.node_id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("revocation failed: {err}\n"),
)
.into_response(),
}
}
pub(super) async fn cluster_members(
Extension(mesh_control): Extension<MeshControlHandle>,
) -> Response {
let Some(control) = mesh_control.0 else {
return (
StatusCode::NOT_IMPLEMENTED,
"this node is not a cluster node\n",
)
.into_response();
};
match control.members().await {
Ok(members) => Json(members).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("listing membership failed: {err}\n"),
)
.into_response(),
}
}
#[derive(Deserialize)]
pub(super) struct PromoteRequest {
node_id: u64,
}
pub(super) async fn cluster_promote(
Extension(mesh_control): Extension<MeshControlHandle>,
Json(request): Json<PromoteRequest>,
) -> Response {
let Some(control) = mesh_control.0 else {
return (
StatusCode::NOT_IMPLEMENTED,
"this node is not a cluster node\n",
)
.into_response();
};
match control.promote(request.node_id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("promotion failed: {err}\n"),
)
.into_response(),
}
}
pub(super) async fn list_tokens(State(deploy): State<DeployStore>) -> Response {
match deploy.list_token_meta().await {
Ok(mut tokens) => {
tokens.sort_by_key(|m| m.created_at);
Json(tokens).into_response()
}
Err(err) => deploy_error_response(err),
}
}
pub(super) async fn revoke_token(
State(deploy): State<DeployStore>,
Path(id): Path<String>,
) -> Response {
match deploy.revoke_token(&id).await {
Ok(true) => StatusCode::NO_CONTENT.into_response(),
Ok(false) => (StatusCode::NOT_FOUND, "no matching token\n").into_response(),
Err(err) => deploy_error_response(err),
}
}
const DEFAULT_JOIN_TOKEN_TTL_SECS: u64 = 3600;
#[derive(Deserialize, Default)]
pub(super) struct CreateJoinTokenRequest {
#[serde(default)]
pub(super) ttl_secs: Option<u64>,
}
#[derive(Serialize)]
struct CreateJoinTokenResponse {
token: String,
expires_at: u64,
}
pub(super) async fn create_join_token(
Extension(issuer): Extension<Issuer>,
Json(request): Json<CreateJoinTokenRequest>,
) -> Response {
let Some(signer) = issuer.0 else {
return (
StatusCode::NOT_IMPLEMENTED,
"this node has no root private key and cannot issue join tokens\n",
)
.into_response();
};
let ttl = request.ttl_secs.unwrap_or(DEFAULT_JOIN_TOKEN_TTL_SECS);
let now = now_unix();
match cose::mint_join(ttl, now, &*signer).await {
Ok(token) => (
StatusCode::CREATED,
Json(CreateJoinTokenResponse {
token,
expires_at: now.saturating_add(ttl),
}),
)
.into_response(),
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
}
}
#[derive(Serialize)]
struct WhoAmI {
auth_enabled: bool,
roles: Vec<GrantedRole>,
}
pub(super) async fn auth_whoami(Extension(auth): Extension<Auth>, headers: HeaderMap) -> Response {
if auth.is_disabled() {
return Json(WhoAmI {
auth_enabled: false,
roles: Vec::new(),
})
.into_response();
}
let Some(bearer) = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
else {
return (StatusCode::UNAUTHORIZED, "missing bearer token\n").into_response();
};
match auth.verify_bearer_roles(bearer).await {
Some(roles) => Json(WhoAmI {
auth_enabled: true,
roles,
})
.into_response(),
None => (StatusCode::UNAUTHORIZED, "invalid token\n").into_response(),
}
}
pub(super) async fn get_authz_policy(State(deploy): State<DeployStore>) -> Response {
match deploy.get_authz_policy().await {
Ok(Some(policy)) => Json(policy).into_response(),
Ok(None) => Json(boatramp_core::authz::AuthzPolicy::default_policy()).into_response(),
Err(err) => deploy_error_response(err),
}
}
pub(super) async fn put_authz_policy(
State(deploy): State<DeployStore>,
Json(policy): Json<boatramp_core::authz::AuthzPolicy>,
) -> Response {
if let Err(err) = boatramp_core::cedar::CompiledCedar::compile(&policy) {
return (StatusCode::BAD_REQUEST, format!("invalid policy: {err}\n")).into_response();
}
match deploy.set_authz_policy(&policy).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => deploy_error_response(err),
}
}
pub(super) async fn list_root_anchors(State(deploy): State<DeployStore>) -> Response {
match deploy.list_root_anchors().await {
Ok(anchors) => (StatusCode::OK, Json(anchors)).into_response(),
Err(err) => deploy_error_response(err),
}
}
#[derive(Deserialize)]
pub(super) struct RootAnchorRequest {
pubkey: String,
}
pub(super) async fn add_root_anchor(
State(deploy): State<DeployStore>,
Json(req): Json<RootAnchorRequest>,
) -> Response {
let pubkey = req.pubkey.trim();
if cose::TokenPublicKey::from_hex(pubkey).is_err() {
return (
StatusCode::BAD_REQUEST,
"pubkey must be an alg:hex TokenPublicKey (e.g. es256:…)\n",
)
.into_response();
}
match deploy.add_root_anchor(pubkey).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => deploy_error_response(err),
}
}
pub(super) async fn remove_root_anchor(
State(deploy): State<DeployStore>,
Path(pubkey): Path<String>,
) -> Response {
match deploy.remove_root_anchor(pubkey.trim()).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => deploy_error_response(err),
}
}