Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions control-plane/api-gateway/src/routes/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,45 @@ async fn proxy_to_registry(
}
}

pub async fn create_bootstrap_token(
AuthenticatedUser(_claims): AuthenticatedUser,
State(state): State<AppState>,
headers: HeaderMap,
body: String,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let body_json: Option<serde_json::Value> = serde_json::from_str(&body).ok();
let header_map: Vec<(String, String)> = headers
.iter()
.filter_map(|(k, v)| v.to_str().ok().map(|val| (k.to_string(), val.to_string())))
.collect();
proxy_to_registry(&state, reqwest::Method::POST, "/admin/bootstrap-tokens", body_json, Some(header_map)).await
}

pub async fn list_bootstrap_tokens(
AuthenticatedUser(_claims): AuthenticatedUser,
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let header_map: Vec<(String, String)> = headers
.iter()
.filter_map(|(k, v)| v.to_str().ok().map(|val| (k.to_string(), val.to_string())))
.collect();
proxy_to_registry(&state, reqwest::Method::GET, "/admin/bootstrap-tokens", None, Some(header_map)).await
}

pub async fn revoke_bootstrap_token(
AuthenticatedUser(_claims): AuthenticatedUser,
State(state): State<AppState>,
Path(id): Path<String>,
headers: HeaderMap,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let header_map: Vec<(String, String)> = headers
.iter()
.filter_map(|(k, v)| v.to_str().ok().map(|val| (k.to_string(), val.to_string())))
.collect();
proxy_to_registry(&state, reqwest::Method::POST, &format!("/admin/bootstrap-tokens/{}/revoke", id), None, Some(header_map)).await
}

/// Registry health check
#[utoipa::path(
get,
Expand Down Expand Up @@ -811,6 +850,10 @@ pub fn registry_routes() -> Router<AppState> {
"/registry/admin/agents/pending/:id",
post(delete_pending_agent),
)
// Admin routes - Bootstrap Token Management
.route("/registry/admin/bootstrap-tokens", post(create_bootstrap_token))
.route("/registry/admin/bootstrap-tokens", get(list_bootstrap_tokens))
.route("/registry/admin/bootstrap-tokens/:id/revoke", post(revoke_bootstrap_token))
// Admin routes - Token Management (DEPRECATED)
.route("/registry/admin/tokens", post(create_token))
.route("/registry/admin/tokens", get(list_tokens))
Expand Down
86 changes: 86 additions & 0 deletions control-plane/registry/src/db/bootstrap_tokens.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
use anyhow::Result;
use entity::bootstrap_tokens;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set,
};
use uuid::Uuid;

pub async fn create(
db: &DatabaseConnection,
token: String,
description: Option<String>,
created_by: String,
expires_at: chrono::DateTime<chrono::Utc>,
max_uses: i32,
) -> Result<bootstrap_tokens::Model> {
let model = bootstrap_tokens::ActiveModel {
id: Set(Uuid::new_v4()),
token: Set(token),
description: Set(description),
created_by: Set(created_by),
created_at: Set(chrono::Utc::now().naive_utc()),
expires_at: Set(expires_at.naive_utc()),
max_uses: Set(max_uses),
use_count: Set(0),
revoked: Set(false),
revoked_at: Set(None),
};
Ok(model.insert(db).await?)
}

pub async fn get_by_token(
db: &DatabaseConnection,
token: &str,
) -> Result<Option<bootstrap_tokens::Model>> {
Ok(bootstrap_tokens::Entity::find()
.filter(bootstrap_tokens::Column::Token.eq(token))
.one(db)
.await?)
}

pub async fn increment_use_count(db: &DatabaseConnection, id: Uuid) -> Result<()> {
let mut model: bootstrap_tokens::ActiveModel = bootstrap_tokens::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| anyhow::anyhow!("Bootstrap token not found"))?
.into();

let current = match &model.use_count {
sea_orm::ActiveValue::Unchanged(v) | sea_orm::ActiveValue::Set(v) => *v,
_ => 0,
};
model.use_count = Set(current + 1);
model.update(db).await?;
Ok(())
}

pub async fn revoke(db: &DatabaseConnection, id: Uuid) -> Result<()> {
let mut model: bootstrap_tokens::ActiveModel = bootstrap_tokens::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| anyhow::anyhow!("Bootstrap token not found"))?
.into();

model.revoked = Set(true);
model.revoked_at = Set(Some(chrono::Utc::now().naive_utc()));
model.update(db).await?;
Ok(())
}

pub async fn get_all_active(
db: &DatabaseConnection,
) -> Result<Vec<bootstrap_tokens::Model>> {
Ok(bootstrap_tokens::Entity::find()
.filter(bootstrap_tokens::Column::Revoked.eq(false))
.all(db)
.await?)
}

pub async fn delete_expired(db: &DatabaseConnection) -> Result<u64> {
let now = chrono::Utc::now().naive_utc();
let result = bootstrap_tokens::Entity::delete_many()
.filter(bootstrap_tokens::Column::ExpiresAt.lt(now))
.exec(db)
.await?;
Ok(result.rows_affected)
}
1 change: 1 addition & 0 deletions control-plane/registry/src/db/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod agents;
pub mod api_keys;
pub mod bootstrap_tokens;
pub mod certificates;
pub mod tokens;
46 changes: 46 additions & 0 deletions control-plane/registry/src/handlers/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use axum::{
http::StatusCode,
response::Json,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::{
Expand All @@ -11,6 +12,18 @@ use crate::{
services::registry::PreRegisterParams,
};

#[derive(Debug, Deserialize)]
pub struct CreateBootstrapTokenRequest {
pub description: Option<String>,
pub ttl_hours: Option<i64>,
pub max_uses: Option<i32>,
}

#[derive(Debug, Serialize)]
pub struct RevokeBootstrapTokenResponse {
pub message: String,
}

pub async fn pre_register_agent(
State(state): State<AppState>,
Json(request): Json<PreRegisterRequest>,
Expand Down Expand Up @@ -127,3 +140,36 @@ pub async fn get_statistics(
) -> Json<crate::models::agent::AgentStatistics> {
Json(state.agent_registry.get_statistics().await)
}

pub async fn create_bootstrap_token(
State(state): State<AppState>,
Json(request): Json<CreateBootstrapTokenRequest>,
) -> Result<Json<crate::services::bootstrap_tokens::BootstrapToken>, (StatusCode, Json<ErrorResponse>)> {
let ttl_hours = request.ttl_hours.unwrap_or(24 * 30);
let max_uses = request.max_uses.unwrap_or(100);

state
.bootstrap_token_manager
.create(request.description, "admin".to_string(), ttl_hours, max_uses)
.await
.map(Json)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))
}

pub async fn list_bootstrap_tokens(
State(state): State<AppState>,
) -> Json<Vec<crate::services::bootstrap_tokens::BootstrapToken>> {
Json(state.bootstrap_token_manager.list().await)
}

pub async fn revoke_bootstrap_token(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<RevokeBootstrapTokenResponse>, (StatusCode, Json<ErrorResponse>)> {
state
.bootstrap_token_manager
.revoke(id)
.await
.map(|_| Json(RevokeBootstrapTokenResponse { message: format!("Bootstrap token {} revoked", id) }))
.map_err(|e| (StatusCode::NOT_FOUND, Json(ErrorResponse { error: e })))
}
86 changes: 53 additions & 33 deletions control-plane/registry/src/handlers/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,52 +15,72 @@ pub async fn register_agent(
State(state): State<AppState>,
Json(request): Json<RegisterRequest>,
) -> Result<Json<RegisterResponse>, (StatusCode, Json<ErrorResponse>)> {
let token_data = match state
.token_manager
.validate_and_consume_token(&request.registration_token)
.await
{
Ok(token) => token,
Err(e) => {
let agent_id = if crate::services::bootstrap_tokens::BootstrapTokenManager::is_bootstrap_token(
&request.registration_token,
) {
if let Err(e) = state
.bootstrap_token_manager
.validate_and_use(&request.registration_token)
.await
{
return Err((
StatusCode::UNAUTHORIZED,
Json(ErrorResponse {
error: format!("Invalid registration token: {}", e),
error: format!("Invalid bootstrap token: {}", e),
}),
))
));
}
};
uuid::Uuid::new_v4()
} else {
let token_data = match state
.token_manager
.validate_and_consume_token(&request.registration_token)
.await
{
Ok(token) => token,
Err(e) => {
return Err((
StatusCode::UNAUTHORIZED,
Json(ErrorResponse {
error: format!("Invalid registration token: {}", e),
}),
))
}
};

if token_data.expected_name != request.name {
return Err((
StatusCode::FORBIDDEN,
Json(ErrorResponse {
error: format!(
"Agent name mismatch. Expected '{}', got '{}'",
token_data.expected_name, request.name
),
}),
));
}
if token_data.expected_name != request.name {
return Err((
StatusCode::FORBIDDEN,
Json(ErrorResponse {
error: format!(
"Agent name mismatch. Expected '{}', got '{}'",
token_data.expected_name, request.name
),
}),
));
}

if token_data.expected_hostname != request.hostname {
return Err((
StatusCode::FORBIDDEN,
Json(ErrorResponse {
error: format!(
"Agent hostname mismatch. Expected '{}', got '{}'",
token_data.expected_hostname, request.hostname
),
}),
));
}
if token_data.expected_hostname != request.hostname {
return Err((
StatusCode::FORBIDDEN,
Json(ErrorResponse {
error: format!(
"Agent hostname mismatch. Expected '{}', got '{}'",
token_data.expected_hostname, request.hostname
),
}),
));
}

token_data.agent_id
};

let csr_pem = request.csr_pem.clone();

let agent = match state
.agent_registry
.register_agent(RegisterAgentParams {
agent_id: token_data.agent_id,
agent_id,
name: request.name,
hostname: request.hostname,
os_type: request.os_type,
Expand Down
4 changes: 4 additions & 0 deletions control-plane/registry/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ async fn main() -> anyhow::Result<()> {
.expect("Failed to initialize PKI service");

let token_manager = Arc::new(services::tokens::TokenManager::new(db_conn.clone()));
let bootstrap_token_manager = Arc::new(services::bootstrap_tokens::BootstrapTokenManager::new(db_conn.clone()));
let api_key_manager = Arc::new(services::api_keys::ApiKeyManager::new(db_conn.clone()));
let agent_registry = Arc::new(services::registry::AgentRegistry::new(db_conn.clone()));

Expand All @@ -52,6 +53,7 @@ async fn main() -> anyhow::Result<()> {

let state = server::AppState {
token_manager: token_manager.clone(),
bootstrap_token_manager: bootstrap_token_manager.clone(),
api_key_manager: api_key_manager.clone(),
agent_registry: agent_registry.clone(),
pki_service: Arc::new(pki_service),
Expand All @@ -63,11 +65,13 @@ async fn main() -> anyhow::Result<()> {

let token_cleanup_handle = {
let token_mgr = token_manager.clone();
let bootstrap_mgr = bootstrap_token_manager.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(3600));
loop {
interval.tick().await;
token_mgr.cleanup_expired().await;
bootstrap_mgr.cleanup_expired().await;
}
})
};
Expand Down
12 changes: 11 additions & 1 deletion control-plane/registry/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,19 @@ use std::sync::Arc;
use crate::{
handlers::{admin, agent, pki},
metrics,
services::{api_keys::ApiKeyManager, pki::PkiService, registry::AgentRegistry, tokens::TokenManager},
services::{
api_keys::ApiKeyManager,
bootstrap_tokens::BootstrapTokenManager,
pki::PkiService,
registry::AgentRegistry,
tokens::TokenManager,
},
};

#[derive(Clone)]
pub struct AppState {
pub token_manager: Arc<TokenManager>,
pub bootstrap_token_manager: Arc<BootstrapTokenManager>,
pub api_key_manager: Arc<ApiKeyManager>,
pub agent_registry: Arc<AgentRegistry>,
pub pki_service: Arc<PkiService>,
Expand All @@ -42,6 +49,9 @@ pub fn create_router(state: AppState) -> Router {
delete(admin::delete_pending_agent),
)
.route("/admin/tokens", get(admin::list_tokens))
.route("/admin/bootstrap-tokens", post(admin::create_bootstrap_token))
.route("/admin/bootstrap-tokens", get(admin::list_bootstrap_tokens))
.route("/admin/bootstrap-tokens/:id/revoke", post(admin::revoke_bootstrap_token))
.route("/admin/agents", get(admin::list_agents))
.route("/admin/agents/:agent_id", get(admin::get_agent))
.route("/admin/agents/:agent_id", delete(admin::deregister_agent))
Expand Down
Loading
Loading