photoncloud-monorepo/iam/crates/iam-server/src/rest.rs

729 lines
20 KiB
Rust

//! REST HTTP API handlers for IAM.
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
routing::{get, post},
Json, Router,
};
use iam_client::client::{IamClient, IamClientConfig};
use iam_types::{Organization, Principal, PrincipalKind, PrincipalRef, Project, Scope};
use serde::{Deserialize, Serialize};
/// REST API state
#[derive(Clone)]
pub struct RestApiState {
pub server_addr: String,
pub tls_enabled: bool,
pub admin_token: Option<String>,
}
/// Standard REST error response
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub error: ErrorDetail,
pub meta: ResponseMeta,
}
#[derive(Debug, Serialize)]
pub struct ErrorDetail {
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
}
#[derive(Debug, Serialize)]
pub struct ResponseMeta {
pub request_id: String,
pub timestamp: String,
}
impl ResponseMeta {
fn new() -> Self {
Self {
request_id: uuid::Uuid::new_v4().to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
}
}
}
fn iam_client_config(state: &RestApiState) -> IamClientConfig {
let mut config = IamClientConfig::new(&state.server_addr);
if let Some(token) = state.admin_token.as_deref() {
config = config.with_admin_token(token);
}
if !state.tls_enabled {
config = config.without_tls();
}
config
}
/// Standard REST success response
#[derive(Debug, Serialize)]
pub struct SuccessResponse<T> {
pub data: T,
pub meta: ResponseMeta,
}
impl<T> SuccessResponse<T> {
fn new(data: T) -> Self {
Self {
data,
meta: ResponseMeta::new(),
}
}
}
#[derive(Debug, Deserialize)]
pub struct TokenRequest {
pub username: String,
pub password: String,
#[serde(default = "default_ttl")]
pub ttl_seconds: u64,
}
fn default_ttl() -> u64 {
3600
}
#[derive(Debug, Serialize)]
pub struct TokenResponse {
pub token: String,
pub expires_at: String,
}
#[derive(Debug, Deserialize)]
pub struct VerifyRequest {
pub token: String,
}
#[derive(Debug, Serialize)]
pub struct VerifyResponse {
pub valid: bool,
pub principal_id: Option<String>,
pub principal_name: Option<String>,
pub roles: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
pub struct CreateUserRequest {
pub id: String,
pub name: String,
}
#[derive(Debug, Serialize)]
pub struct UserResponse {
pub id: String,
pub name: String,
pub kind: String,
pub org_id: Option<String>,
pub project_id: Option<String>,
pub enabled: bool,
}
impl From<Principal> for UserResponse {
fn from(p: Principal) -> Self {
Self {
id: p.id,
name: p.name,
kind: format!("{:?}", p.kind),
org_id: p.org_id,
project_id: p.project_id,
enabled: p.enabled,
}
}
}
#[derive(Debug, Serialize)]
pub struct UsersResponse {
pub users: Vec<UserResponse>,
}
#[derive(Debug, Deserialize)]
pub struct CreateOrganizationRequest {
pub id: String,
pub name: String,
#[serde(default)]
pub description: String,
}
#[derive(Debug, Deserialize)]
pub struct UpdateOrganizationRequest {
pub name: Option<String>,
pub description: Option<String>,
pub enabled: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct OrganizationResponse {
pub id: String,
pub name: String,
pub description: String,
pub enabled: bool,
}
impl From<Organization> for OrganizationResponse {
fn from(org: Organization) -> Self {
Self {
id: org.id,
name: org.name,
description: org.description,
enabled: org.enabled,
}
}
}
#[derive(Debug, Serialize)]
pub struct OrganizationsResponse {
pub organizations: Vec<OrganizationResponse>,
}
#[derive(Debug, Deserialize)]
pub struct CreateProjectRequest {
pub id: String,
pub org_id: String,
pub name: String,
#[serde(default)]
pub description: String,
}
#[derive(Debug, Deserialize)]
pub struct UpdateProjectRequest {
pub name: Option<String>,
pub description: Option<String>,
pub enabled: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub struct ProjectsQuery {
pub org_id: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ProjectResponse {
pub id: String,
pub org_id: String,
pub name: String,
pub description: String,
pub enabled: bool,
}
impl From<Project> for ProjectResponse {
fn from(project: Project) -> Self {
Self {
id: project.id,
org_id: project.org_id,
name: project.name,
description: project.description,
enabled: project.enabled,
}
}
}
#[derive(Debug, Serialize)]
pub struct ProjectsResponse {
pub projects: Vec<ProjectResponse>,
}
pub fn build_router(state: RestApiState) -> Router {
Router::new()
.route("/api/v1/auth/token", post(issue_token))
.route("/api/v1/auth/verify", post(verify_token))
.route("/api/v1/users", get(list_users).post(create_user))
.route("/api/v1/users/{id}", get(get_user))
.route(
"/api/v1/orgs",
get(list_organizations).post(create_organization),
)
.route(
"/api/v1/orgs/{org_id}",
get(get_organization)
.patch(update_organization)
.delete(delete_organization),
)
.route("/api/v1/projects", get(list_projects).post(create_project))
.route(
"/api/v1/orgs/{org_id}/projects/{project_id}",
get(get_project)
.patch(update_project)
.delete(delete_project),
)
.route("/health", get(health_check))
.with_state(state)
}
async fn health_check() -> (StatusCode, Json<SuccessResponse<serde_json::Value>>) {
(
StatusCode::OK,
Json(SuccessResponse::new(
serde_json::json!({ "status": "healthy" }),
)),
)
}
fn require_admin(
state: &RestApiState,
headers: &HeaderMap,
) -> Result<(), (StatusCode, Json<ErrorResponse>)> {
let Some(token) = state.admin_token.as_deref() else {
return Ok(());
};
if let Some(value) = headers.get("x-iam-admin-token") {
if let Ok(raw) = value.to_str() {
if raw.trim() == token {
return Ok(());
}
}
}
if let Some(value) = headers.get("authorization") {
if let Ok(raw) = value.to_str() {
let raw = raw.trim();
if let Some(rest) = raw
.strip_prefix("Bearer ")
.or_else(|| raw.strip_prefix("bearer "))
{
if rest.trim() == token {
return Ok(());
}
}
}
}
Err(error_response(
StatusCode::UNAUTHORIZED,
"ADMIN_TOKEN_REQUIRED",
"missing or invalid IAM admin token",
))
}
async fn connect_client(
state: &RestApiState,
) -> Result<IamClient, (StatusCode, Json<ErrorResponse>)> {
IamClient::connect(iam_client_config(state))
.await
.map_err(|e| {
error_response(
StatusCode::SERVICE_UNAVAILABLE,
"SERVICE_UNAVAILABLE",
&format!("Failed to connect: {}", e),
)
})
}
async fn issue_token(
headers: HeaderMap,
State(state): State<RestApiState>,
Json(req): Json<TokenRequest>,
) -> Result<Json<SuccessResponse<TokenResponse>>, (StatusCode, Json<ErrorResponse>)> {
require_admin(&state, &headers)?;
if !allow_insecure_rest_token_issue() {
return Err(error_response(
StatusCode::FORBIDDEN,
"TOKEN_ISSUE_DISABLED",
"token issuance is disabled; enable IAM_REST_ALLOW_INSECURE_TOKEN=true for dev",
));
}
let TokenRequest {
username,
password: _password,
ttl_seconds,
} = req;
let client = connect_client(&state).await?;
let principal_ref = PrincipalRef::new(PrincipalKind::User, &username);
let principal = match client.get_principal(&principal_ref).await.map_err(|e| {
error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"PRINCIPAL_LOOKUP_FAILED",
&e.to_string(),
)
})? {
Some(principal) => principal,
None => {
return Err(error_response(
StatusCode::NOT_FOUND,
"PRINCIPAL_NOT_FOUND",
"principal not found",
))
}
};
if !principal.enabled {
return Err(error_response(
StatusCode::FORBIDDEN,
"PRINCIPAL_DISABLED",
"principal is disabled",
));
}
let token = client
.issue_token(&principal, vec![], Scope::System, ttl_seconds)
.await
.map_err(|e| {
error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"TOKEN_ISSUE_FAILED",
&e.to_string(),
)
})?;
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(ttl_seconds as i64);
Ok(Json(SuccessResponse::new(TokenResponse {
token,
expires_at: expires_at.to_rfc3339(),
})))
}
fn allow_insecure_rest_token_issue() -> bool {
std::env::var("IAM_REST_ALLOW_INSECURE_TOKEN")
.or_else(|_| std::env::var("PHOTON_IAM_REST_ALLOW_INSECURE_TOKEN"))
.ok()
.map(|value| {
matches!(
value.trim().to_lowercase().as_str(),
"1" | "true" | "yes" | "y" | "on"
)
})
.unwrap_or(false)
}
async fn verify_token(
State(state): State<RestApiState>,
Json(req): Json<VerifyRequest>,
) -> Result<Json<SuccessResponse<VerifyResponse>>, (StatusCode, Json<ErrorResponse>)> {
let client = connect_client(&state).await?;
let result = client.validate_token(&req.token).await;
match result {
Ok(claims) => Ok(Json(SuccessResponse::new(VerifyResponse {
valid: true,
principal_id: Some(claims.principal_id),
principal_name: Some(claims.principal_name),
roles: Some(claims.roles),
}))),
Err(_) => Ok(Json(SuccessResponse::new(VerifyResponse {
valid: false,
principal_id: None,
principal_name: None,
roles: None,
}))),
}
}
async fn create_user(
headers: HeaderMap,
State(state): State<RestApiState>,
Json(req): Json<CreateUserRequest>,
) -> Result<(StatusCode, Json<SuccessResponse<UserResponse>>), (StatusCode, Json<ErrorResponse>)> {
require_admin(&state, &headers)?;
let client = connect_client(&state).await?;
let principal = client.create_user(&req.id, &req.name).await.map_err(|e| {
error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"USER_CREATE_FAILED",
&e.to_string(),
)
})?;
Ok((
StatusCode::CREATED,
Json(SuccessResponse::new(UserResponse::from(principal))),
))
}
async fn list_users(
headers: HeaderMap,
State(state): State<RestApiState>,
) -> Result<Json<SuccessResponse<UsersResponse>>, (StatusCode, Json<ErrorResponse>)> {
require_admin(&state, &headers)?;
let client = connect_client(&state).await?;
let principals = client.list_users().await.map_err(|e| {
error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"USER_LIST_FAILED",
&e.to_string(),
)
})?;
let users: Vec<UserResponse> = principals.into_iter().map(UserResponse::from).collect();
Ok(Json(SuccessResponse::new(UsersResponse { users })))
}
async fn get_user(
headers: HeaderMap,
Path(id): Path<String>,
State(state): State<RestApiState>,
) -> Result<Json<SuccessResponse<UserResponse>>, (StatusCode, Json<ErrorResponse>)> {
require_admin(&state, &headers)?;
let client = connect_client(&state).await?;
let principal = client
.get_principal(&PrincipalRef::user(id))
.await
.map_err(|e| {
error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"USER_LOOKUP_FAILED",
&e.to_string(),
)
})?
.ok_or_else(|| error_response(StatusCode::NOT_FOUND, "USER_NOT_FOUND", "user not found"))?;
Ok(Json(SuccessResponse::new(UserResponse::from(principal))))
}
async fn create_organization(
headers: HeaderMap,
State(state): State<RestApiState>,
Json(req): Json<CreateOrganizationRequest>,
) -> Result<
(StatusCode, Json<SuccessResponse<OrganizationResponse>>),
(StatusCode, Json<ErrorResponse>),
> {
require_admin(&state, &headers)?;
let client = connect_client(&state).await?;
let org = client
.create_organization(&req.id, &req.name, &req.description)
.await
.map_err(|e| {
error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"ORG_CREATE_FAILED",
&e.to_string(),
)
})?;
Ok((StatusCode::CREATED, Json(SuccessResponse::new(org.into()))))
}
async fn list_organizations(
headers: HeaderMap,
State(state): State<RestApiState>,
) -> Result<Json<SuccessResponse<OrganizationsResponse>>, (StatusCode, Json<ErrorResponse>)> {
require_admin(&state, &headers)?;
let client = connect_client(&state).await?;
let organizations = client.list_organizations().await.map_err(|e| {
error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"ORG_LIST_FAILED",
&e.to_string(),
)
})?;
Ok(Json(SuccessResponse::new(OrganizationsResponse {
organizations: organizations.into_iter().map(Into::into).collect(),
})))
}
async fn get_organization(
headers: HeaderMap,
Path(org_id): Path<String>,
State(state): State<RestApiState>,
) -> Result<Json<SuccessResponse<OrganizationResponse>>, (StatusCode, Json<ErrorResponse>)> {
require_admin(&state, &headers)?;
let client = connect_client(&state).await?;
let organization = client
.get_organization(&org_id)
.await
.map_err(|e| {
error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"ORG_LOOKUP_FAILED",
&e.to_string(),
)
})?
.ok_or_else(|| {
error_response(
StatusCode::NOT_FOUND,
"ORG_NOT_FOUND",
"organization not found",
)
})?;
Ok(Json(SuccessResponse::new(organization.into())))
}
async fn update_organization(
headers: HeaderMap,
Path(org_id): Path<String>,
State(state): State<RestApiState>,
Json(req): Json<UpdateOrganizationRequest>,
) -> Result<Json<SuccessResponse<OrganizationResponse>>, (StatusCode, Json<ErrorResponse>)> {
require_admin(&state, &headers)?;
let client = connect_client(&state).await?;
let organization = client
.update_organization(
&org_id,
req.name.as_deref(),
req.description.as_deref(),
req.enabled,
)
.await
.map_err(|e| {
error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"ORG_UPDATE_FAILED",
&e.to_string(),
)
})?;
Ok(Json(SuccessResponse::new(organization.into())))
}
async fn delete_organization(
headers: HeaderMap,
Path(org_id): Path<String>,
State(state): State<RestApiState>,
) -> Result<Json<SuccessResponse<serde_json::Value>>, (StatusCode, Json<ErrorResponse>)> {
require_admin(&state, &headers)?;
let client = connect_client(&state).await?;
let deleted = client.delete_organization(&org_id).await.map_err(|e| {
error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"ORG_DELETE_FAILED",
&e.to_string(),
)
})?;
Ok(Json(SuccessResponse::new(
serde_json::json!({ "deleted": deleted }),
)))
}
async fn create_project(
headers: HeaderMap,
State(state): State<RestApiState>,
Json(req): Json<CreateProjectRequest>,
) -> Result<(StatusCode, Json<SuccessResponse<ProjectResponse>>), (StatusCode, Json<ErrorResponse>)>
{
require_admin(&state, &headers)?;
let client = connect_client(&state).await?;
let project = client
.create_project(&req.org_id, &req.id, &req.name, &req.description)
.await
.map_err(|e| {
error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"PROJECT_CREATE_FAILED",
&e.to_string(),
)
})?;
Ok((
StatusCode::CREATED,
Json(SuccessResponse::new(project.into())),
))
}
async fn list_projects(
headers: HeaderMap,
Query(query): Query<ProjectsQuery>,
State(state): State<RestApiState>,
) -> Result<Json<SuccessResponse<ProjectsResponse>>, (StatusCode, Json<ErrorResponse>)> {
require_admin(&state, &headers)?;
let client = connect_client(&state).await?;
let projects = client
.list_projects(query.org_id.as_deref())
.await
.map_err(|e| {
error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"PROJECT_LIST_FAILED",
&e.to_string(),
)
})?;
Ok(Json(SuccessResponse::new(ProjectsResponse {
projects: projects.into_iter().map(Into::into).collect(),
})))
}
async fn get_project(
headers: HeaderMap,
Path((org_id, project_id)): Path<(String, String)>,
State(state): State<RestApiState>,
) -> Result<Json<SuccessResponse<ProjectResponse>>, (StatusCode, Json<ErrorResponse>)> {
require_admin(&state, &headers)?;
let client = connect_client(&state).await?;
let project = client
.get_project(&org_id, &project_id)
.await
.map_err(|e| {
error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"PROJECT_LOOKUP_FAILED",
&e.to_string(),
)
})?
.ok_or_else(|| {
error_response(
StatusCode::NOT_FOUND,
"PROJECT_NOT_FOUND",
"project not found",
)
})?;
Ok(Json(SuccessResponse::new(project.into())))
}
async fn update_project(
headers: HeaderMap,
Path((org_id, project_id)): Path<(String, String)>,
State(state): State<RestApiState>,
Json(req): Json<UpdateProjectRequest>,
) -> Result<Json<SuccessResponse<ProjectResponse>>, (StatusCode, Json<ErrorResponse>)> {
require_admin(&state, &headers)?;
let client = connect_client(&state).await?;
let project = client
.update_project(
&org_id,
&project_id,
req.name.as_deref(),
req.description.as_deref(),
req.enabled,
)
.await
.map_err(|e| {
error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"PROJECT_UPDATE_FAILED",
&e.to_string(),
)
})?;
Ok(Json(SuccessResponse::new(project.into())))
}
async fn delete_project(
headers: HeaderMap,
Path((org_id, project_id)): Path<(String, String)>,
State(state): State<RestApiState>,
) -> Result<Json<SuccessResponse<serde_json::Value>>, (StatusCode, Json<ErrorResponse>)> {
require_admin(&state, &headers)?;
let client = connect_client(&state).await?;
let deleted = client
.delete_project(&org_id, &project_id)
.await
.map_err(|e| {
error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"PROJECT_DELETE_FAILED",
&e.to_string(),
)
})?;
Ok(Json(SuccessResponse::new(
serde_json::json!({ "deleted": deleted }),
)))
}
fn error_response(
status: StatusCode,
code: &str,
message: &str,
) -> (StatusCode, Json<ErrorResponse>) {
(
status,
Json(ErrorResponse {
error: ErrorDetail {
code: code.to_string(),
message: message.to_string(),
details: None,
},
meta: ResponseMeta::new(),
}),
)
}