lightscale-admin/backend/src/api/networks.rs
centra 98eb7057a5
Some checks failed
build-local-image / build (push) Has been cancelled
Implement user-bound join flows and add admin image build pipeline
2026-02-14 15:46:25 +09:00

677 lines
21 KiB
Rust

use crate::api::{ApiError, AuthUser};
use crate::audit_log;
use crate::control_plane::{
AdminNodesResponse, CreateNetworkRequest as CpCreateNetworkRequest,
CreateTokenRequest as CpCreateTokenRequest, CreateTokenResponse, EnrollmentToken,
KeyHistoryResponse, KeyPolicyResponse, KeyRotationRequest, KeyRotationResponse,
KeyRotationPolicy, NetworkInfo,
};
use crate::models::{ControlPlane, Network};
use crate::permissions::{
ensure_permission_global, PERM_ACL_READ, PERM_ACL_WRITE, PERM_KEY_POLICY_READ,
PERM_KEY_POLICY_WRITE, PERM_NETWORKS_READ, PERM_NETWORKS_WRITE, PERM_NODES_READ,
PERM_NODES_WRITE, PERM_TOKENS_WRITE, PERM_AUDIT_READ,
};
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::PgPool;
use time::OffsetDateTime;
use uuid::Uuid;
#[derive(Debug, Serialize)]
struct NetworkSummary {
id: Uuid,
control_plane_id: Uuid,
control_plane_name: String,
network_id: String,
name: String,
dns_domain: Option<String>,
overlay_v4: Option<String>,
overlay_v6: Option<String>,
requires_approval: bool,
created_at: OffsetDateTime,
updated_at: OffsetDateTime,
}
#[derive(Debug, Deserialize)]
struct CreateNetworkRequest {
control_plane_id: Uuid,
name: String,
overlay_v4: Option<String>,
overlay_v6: Option<String>,
dns_domain: Option<String>,
requires_approval: Option<bool>,
key_rotation_max_age_seconds: Option<u64>,
bootstrap_token_ttl_seconds: Option<u64>,
bootstrap_token_uses: Option<u32>,
bootstrap_token_tags: Option<Vec<String>>,
}
#[derive(Debug, Serialize)]
struct CreateNetworkResult {
network: NetworkSummary,
bootstrap_token: Option<EnrollmentToken>,
}
#[derive(Debug, Deserialize)]
struct CreateTokenRequest {
ttl_seconds: u64,
uses: u32,
tags: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct RevokeTokenRequest {
token_id: String,
}
#[derive(Debug, Deserialize)]
struct RotateKeysRequest {
machine_public_key: Option<String>,
wg_public_key: Option<String>,
}
#[derive(Debug, Deserialize)]
struct UpdateAclRequest {
policy: Value,
}
#[derive(Debug, Deserialize)]
struct NetworkAuditQuery {
node_id: Option<String>,
limit: Option<usize>,
}
pub fn router() -> Router<crate::app_state::AppState> {
Router::new()
.route("/", get(list_networks).post(create_network))
.route("/:id", get(get_network).delete(delete_network))
.route("/:id/tokens", post(create_token))
.route("/:id/tokens/revoke", post(revoke_token))
.route("/:id/nodes", get(list_nodes))
.route("/:id/nodes/:node_id/approve", post(approve_node))
.route("/:id/nodes/:node_id/revoke", post(revoke_node))
.route("/:id/nodes/:node_id/rotate-keys", post(rotate_keys))
.route("/:id/nodes/:node_id/keys", get(node_keys))
.route("/:id/acl", get(get_acl).put(update_acl))
.route("/:id/key-policy", get(get_key_policy).put(update_key_policy))
.route("/:id/audit", get(network_audit))
}
async fn list_networks(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
) -> Result<Json<Vec<NetworkSummary>>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_NETWORKS_READ).await?;
let rows = sqlx::query_as::<_, NetworkRow>(
r#"
SELECT n.id,
n.control_plane_id,
n.network_id,
n.name,
n.dns_domain,
n.overlay_v4,
n.overlay_v6,
n.requires_approval,
n.created_at,
n.updated_at,
c.name AS control_plane_name
FROM networks n
JOIN control_planes c ON c.id = n.control_plane_id
ORDER BY n.created_at DESC
"#,
)
.fetch_all(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
let out = rows
.into_iter()
.map(|row| NetworkSummary {
id: row.id,
control_plane_id: row.control_plane_id,
control_plane_name: row.control_plane_name,
network_id: row.network_id,
name: row.name,
dns_domain: row.dns_domain,
overlay_v4: row.overlay_v4,
overlay_v6: row.overlay_v6,
requires_approval: row.requires_approval,
created_at: row.created_at,
updated_at: row.updated_at,
})
.collect();
Ok(Json(out))
}
async fn get_network(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<NetworkSummary>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_NETWORKS_READ).await?;
let row = fetch_network_summary(&state.pool, id).await?;
Ok(Json(NetworkSummary {
id: row.id,
control_plane_id: row.control_plane_id,
control_plane_name: row.control_plane_name,
network_id: row.network_id,
name: row.name,
dns_domain: row.dns_domain,
overlay_v4: row.overlay_v4,
overlay_v6: row.overlay_v6,
requires_approval: row.requires_approval,
created_at: row.created_at,
updated_at: row.updated_at,
}))
}
async fn delete_network(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_NETWORKS_WRITE).await?;
let (network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
// Send delete request to control plane
client
.delete_network(&network.network_id)
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
// Delete from database
sqlx::query("DELETE FROM networks WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
audit_log::record(
&state.pool,
Some(user.id),
"network.delete",
Some("network"),
Some(&network.id.to_string()),
Some(serde_json::json!({
"network_id": network.network_id,
"control_plane_id": control_plane.id,
})),
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
async fn create_network(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Json(req): Json<CreateNetworkRequest>,
) -> Result<Json<CreateNetworkResult>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_NETWORKS_WRITE).await?;
let control_plane = fetch_control_plane(&state.pool, req.control_plane_id).await?;
let client = crate::control_plane::ControlPlaneClient::new(
control_plane.base_url.clone(),
control_plane.admin_token.clone(),
);
let response = client
.create_network(CpCreateNetworkRequest {
name: req.name,
overlay_v4: req.overlay_v4.clone(),
overlay_v6: req.overlay_v6.clone(),
dns_domain: req.dns_domain.clone(),
requires_approval: req.requires_approval,
key_rotation_max_age_seconds: req.key_rotation_max_age_seconds,
bootstrap_token_ttl_seconds: req.bootstrap_token_ttl_seconds,
bootstrap_token_uses: req.bootstrap_token_uses,
bootstrap_token_tags: req.bootstrap_token_tags.clone(),
})
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
let record = insert_network(
&state.pool,
&control_plane,
&response.network,
req.dns_domain,
)
.await?;
audit_log::record(
&state.pool,
Some(user.id),
"network.create",
Some("network"),
Some(&record.id.to_string()),
Some(serde_json::json!({
"network_id": response.network.id,
"control_plane_id": control_plane.id,
})),
)
.await?;
let summary = fetch_network_summary(&state.pool, record.id).await?;
Ok(Json(CreateNetworkResult {
network: NetworkSummary {
id: summary.id,
control_plane_id: summary.control_plane_id,
control_plane_name: summary.control_plane_name,
network_id: summary.network_id,
name: summary.name,
dns_domain: summary.dns_domain,
overlay_v4: summary.overlay_v4,
overlay_v6: summary.overlay_v6,
requires_approval: summary.requires_approval,
created_at: summary.created_at,
updated_at: summary.updated_at,
},
bootstrap_token: response.bootstrap_token,
}))
}
async fn create_token(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
Json(req): Json<CreateTokenRequest>,
) -> Result<Json<CreateTokenResponse>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_TOKENS_WRITE).await?;
let (network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
let response = client
.create_token(
&network.network_id,
CpCreateTokenRequest {
ttl_seconds: req.ttl_seconds,
uses: req.uses,
tags: req.tags,
owner_user_id: Some(user.id.to_string()),
owner_email: Some(user.email.clone()),
owner_is_admin: Some(true),
},
)
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
audit_log::record(
&state.pool,
Some(user.id),
"token.create",
Some("network"),
Some(&network.id.to_string()),
None,
)
.await?;
Ok(Json(response))
}
async fn revoke_token(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
Json(req): Json<RevokeTokenRequest>,
) -> Result<Json<EnrollmentToken>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_TOKENS_WRITE).await?;
let (network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
let response = client
.revoke_token(&req.token_id)
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
audit_log::record(
&state.pool,
Some(user.id),
"token.revoke",
Some("network"),
Some(&network.id.to_string()),
Some(serde_json::json!({ "token_id": req.token_id })),
)
.await?;
Ok(Json(response))
}
async fn list_nodes(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<AdminNodesResponse>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_NODES_READ).await?;
let (network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
let response = client
.list_nodes(&network.network_id)
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
Ok(Json(response))
}
async fn approve_node(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path((id, node_id)): Path<(Uuid, String)>,
) -> Result<Json<crate::control_plane::ApproveNodeResponse>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_NODES_WRITE).await?;
let (_network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
let response = client
.approve_node(&node_id)
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
audit_log::record(
&state.pool,
Some(user.id),
"node.approve",
Some("node"),
Some(&node_id),
None,
)
.await?;
Ok(Json(response))
}
async fn revoke_node(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path((id, node_id)): Path<(Uuid, String)>,
) -> Result<Json<crate::control_plane::RevokeNodeResponse>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_NODES_WRITE).await?;
let (_network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
let response = client
.revoke_node(&node_id)
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
audit_log::record(
&state.pool,
Some(user.id),
"node.revoke",
Some("node"),
Some(&node_id),
None,
)
.await?;
Ok(Json(response))
}
async fn rotate_keys(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path((id, node_id)): Path<(Uuid, String)>,
Json(req): Json<RotateKeysRequest>,
) -> Result<Json<KeyRotationResponse>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_NODES_WRITE).await?;
let (_network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
let response = client
.rotate_keys(
&node_id,
KeyRotationRequest {
machine_public_key: req.machine_public_key,
wg_public_key: req.wg_public_key,
},
)
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
audit_log::record(
&state.pool,
Some(user.id),
"node.rotate_keys",
Some("node"),
Some(&node_id),
None,
)
.await?;
Ok(Json(response))
}
async fn node_keys(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path((id, node_id)): Path<(Uuid, String)>,
) -> Result<Json<KeyHistoryResponse>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_NODES_READ).await?;
let (_network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
let response = client
.node_keys(&node_id)
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
Ok(Json(response))
}
async fn get_acl(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<Value>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_ACL_READ).await?;
let (network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
let response = client
.get_acl(&network.network_id)
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
Ok(Json(response))
}
async fn update_acl(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
Json(req): Json<UpdateAclRequest>,
) -> Result<Json<Value>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_ACL_WRITE).await?;
let (network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
let response = client
.update_acl(&network.network_id, req.policy)
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
audit_log::record(
&state.pool,
Some(user.id),
"network.update_acl",
Some("network"),
Some(&network.id.to_string()),
None,
)
.await?;
Ok(Json(response))
}
async fn get_key_policy(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<KeyPolicyResponse>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_KEY_POLICY_READ).await?;
let (network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
let response = client
.get_key_policy(&network.network_id)
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
Ok(Json(response))
}
async fn update_key_policy(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
Json(req): Json<KeyRotationPolicy>,
) -> Result<Json<KeyPolicyResponse>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_KEY_POLICY_WRITE).await?;
let (network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
let response = client
.update_key_policy(&network.network_id, req)
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
audit_log::record(
&state.pool,
Some(user.id),
"network.update_key_policy",
Some("network"),
Some(&network.id.to_string()),
None,
)
.await?;
Ok(Json(response))
}
async fn network_audit(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
Query(query): Query<NetworkAuditQuery>,
) -> Result<Json<crate::control_plane::AuditLogResponse>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_AUDIT_READ).await?;
let (network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
let response = client
.audit_log(crate::control_plane::AuditLogQuery {
network_id: Some(network.network_id),
node_id: query.node_id,
limit: query.limit,
})
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
Ok(Json(response))
}
#[derive(sqlx::FromRow)]
struct NetworkRow {
id: Uuid,
control_plane_id: Uuid,
control_plane_name: String,
network_id: String,
name: String,
dns_domain: Option<String>,
overlay_v4: Option<String>,
overlay_v6: Option<String>,
requires_approval: bool,
created_at: OffsetDateTime,
updated_at: OffsetDateTime,
}
async fn fetch_network_summary(pool: &PgPool, id: Uuid) -> Result<NetworkRow, ApiError> {
sqlx::query_as::<_, NetworkRow>(
r#"
SELECT n.id,
n.control_plane_id,
n.network_id,
n.name,
n.dns_domain,
n.overlay_v4,
n.overlay_v6,
n.requires_approval,
n.created_at,
n.updated_at,
c.name AS control_plane_name
FROM networks n
JOIN control_planes c ON c.id = n.control_plane_id
WHERE n.id = $1
"#,
)
.bind(id)
.fetch_optional(pool)
.await
.map_err(|_| ApiError::Internal)?
.ok_or(ApiError::NotFound)
}
async fn fetch_network_with_cp(
pool: &PgPool,
network_id: Uuid,
) -> Result<(Network, ControlPlane), ApiError> {
let network = sqlx::query_as::<_, Network>(
r#"
SELECT id, control_plane_id, network_id, name, dns_domain, overlay_v4, overlay_v6,
requires_approval, created_at, updated_at
FROM networks
WHERE id = $1
"#,
)
.bind(network_id)
.fetch_optional(pool)
.await
.map_err(|_| ApiError::Internal)?
.ok_or(ApiError::NotFound)?;
let control_plane = fetch_control_plane(pool, network.control_plane_id).await?;
Ok((network, control_plane))
}
async fn fetch_control_plane(pool: &PgPool, id: Uuid) -> Result<ControlPlane, ApiError> {
sqlx::query_as::<_, ControlPlane>(
r#"
SELECT id, name, base_url, admin_token, region, created_at, updated_at
FROM control_planes
WHERE id = $1
"#,
)
.bind(id)
.fetch_optional(pool)
.await
.map_err(|_| ApiError::Internal)?
.ok_or(ApiError::NotFound)
}
async fn insert_network(
pool: &PgPool,
control_plane: &ControlPlane,
network: &NetworkInfo,
dns_override: Option<String>,
) -> Result<Network, ApiError> {
let now = OffsetDateTime::now_utc();
sqlx::query_as::<_, Network>(
r#"
INSERT INTO networks (
id, control_plane_id, network_id, name, dns_domain, overlay_v4, overlay_v6,
requires_approval, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
RETURNING id, control_plane_id, network_id, name, dns_domain, overlay_v4, overlay_v6,
requires_approval, created_at, updated_at
"#,
)
.bind(Uuid::new_v4())
.bind(control_plane.id)
.bind(&network.id)
.bind(&network.name)
.bind(dns_override.or_else(|| Some(network.dns_domain.clone())))
.bind(&network.overlay_v4)
.bind(&network.overlay_v6)
.bind(network.requires_approval)
.bind(now)
.fetch_one(pool)
.await
.map_err(|_| ApiError::Internal)
}
fn client_for(control_plane: &ControlPlane) -> crate::control_plane::ControlPlaneClient {
crate::control_plane::ControlPlaneClient::new(
control_plane.base_url.clone(),
control_plane.admin_token.clone(),
)
}