bucketwarden-server 0.1.0

BucketWarden storage server runtime.
Documentation
use super::*;

#[derive(Clone, Debug, Deserialize)]
struct ConsoleApiLoginPayload {
    principal_id: String,
    shared_secret: String,
    #[serde(default = "default_console_identity_provider")]
    identity_provider: String,
}

#[derive(Clone, Debug, Deserialize)]
struct ConsoleApiLegalHoldPayload {
    enabled: bool,
    #[serde(default)]
    reason: String,
}

fn default_console_identity_provider() -> String {
    "custom-shared-secret".to_string()
}

#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct ConsoleApiRequest {
    pub method: String,
    pub path: String,
    pub access_key_id: Option<String>,
    pub query: BTreeMap<String, String>,
    pub body: Option<serde_json::Value>,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ConsoleApiResponse {
    pub status: u16,
    pub content_type: String,
    pub request_id: String,
    pub body: serde_json::Value,
}

impl BucketWarden {
    pub fn handle_console_api(
        &mut self,
        request: ConsoleApiRequest,
    ) -> Result<ConsoleApiResponse, RuntimeError> {
        let request_id = self.next_console_api_request_id();
        match self.dispatch_console_api(&request, &request_id) {
            Ok(body) => Ok(ConsoleApiResponse::json(200, request_id, body)),
            Err(error) => {
                let envelope = self.console_api_error_envelope(&error, Some(&request_id));
                Ok(ConsoleApiResponse::json(
                    envelope.status,
                    request_id,
                    serde_json::to_value(envelope).map_err(RuntimeError::SnapshotSerialize)?,
                ))
            }
        }
    }

    fn dispatch_console_api(
        &mut self,
        request: &ConsoleApiRequest,
        _request_id: &str,
    ) -> Result<serde_json::Value, RuntimeError> {
        let method = request.method.as_str();
        let owned_path = canonical_console_api_path(request.path.trim_end_matches('/'));
        let path = owned_path.as_str();
        if method == "POST" && path == "/ui/api/login" {
            let login = serde_json::from_value::<ConsoleApiLoginPayload>(
                request.body.clone().unwrap_or_default(),
            )
            .map_err(RuntimeError::SnapshotDeserialize)?;
            if login.identity_provider != "custom-shared-secret" {
                return Err(RuntimeError::InvalidListParameter {
                    name: "identity_provider".to_string(),
                    value: login.identity_provider,
                });
            }
            return json(self.console_api_login(ConsoleApiLoginRequest {
                principal_id: login.principal_id,
                shared_secret: login.shared_secret,
            })?);
        }
        if method == "GET" && path == "/ui/api/identity-providers" {
            return json(self.console_api_identity_providers());
        }
        let access_key_id = request.access_key_id.as_deref().ok_or_else(|| {
            RuntimeError::InvalidToken("missing console API access key".to_string())
        })?;
        match (method, path) {
            ("GET", "/ui/api/current-user") => json(self.console_api_current_user(access_key_id)?),
            ("POST", "/ui/api/logout") => {
                self.console_api_logout(access_key_id)?;
                json(serde_json::json!({"logged_out": true}))
            }
            ("GET", "/ui/api/overview") => json(self.console_api_overview(access_key_id)?),
            ("GET", "/ui/api/reports") => json(self.console_api_reports_dashboard(access_key_id)?),
            ("GET", "/ui/api/reports/health") => {
                json(self.console_api_health_report(access_key_id)?)
            }
            ("GET", "/ui/api/reports/config") => {
                Ok(self.console_api_redacted_configuration_report(access_key_id)?)
            }
            ("GET", "/ui/api/reports/storage") => {
                json(self.console_api_storage_report(access_key_id)?)
            }
            ("GET", "/ui/api/reports/governance") => {
                Ok(self.console_api_governance_report(access_key_id)?)
            }
            ("GET", "/ui/api/reports/incident") => {
                json(self.console_api_incident_report(access_key_id)?)
            }
            ("GET", "/ui/api/buckets") => {
                json(self.console_api_bucket_list(access_key_id, list_query(request))?)
            }
            ("GET", "/ui/api/audit") => {
                json(self.console_api_audit_events(access_key_id, audit_query(request))?)
            }
            ("GET", "/ui/api/evidence") => {
                json(self.console_api_evidence_list(access_key_id, list_query(request))?)
            }
            ("GET", "/ui/api/evidence-export") => {
                json(self.console_api_evidence_export(access_key_id)?)
            }
            ("GET", "/ui/api/admin") => json(self.console_api_admin_summary(access_key_id)?),
            ("GET", "/ui/api/preferences") => {
                json(self.console_api_preferences_read(access_key_id)?)
            }
            ("PUT", "/ui/api/preferences") => {
                let preferences = serde_json::from_value::<ConsoleApiPreferences>(
                    request.body.clone().unwrap_or_default(),
                )
                .map_err(RuntimeError::SnapshotDeserialize)?;
                json(self.console_api_preferences_write(access_key_id, preferences)?)
            }
            _ => self.dispatch_resource_route(access_key_id, method, path, request),
        }
    }

    fn dispatch_resource_route(
        &mut self,
        access_key_id: &str,
        method: &str,
        path: &str,
        request: &ConsoleApiRequest,
    ) -> Result<serde_json::Value, RuntimeError> {
        let parts = path
            .split('/')
            .filter(|part| !part.is_empty())
            .collect::<Vec<_>>();
        match parts.as_slice() {
            ["ui", "api", "buckets", bucket] if method == "GET" => {
                json(self.console_api_bucket_detail(access_key_id, bucket)?)
            }
            ["ui", "api", "buckets", bucket, "objects"] if method == "GET" => {
                json(self.console_api_object_list(access_key_id, bucket, list_query(request))?)
            }
            ["ui", "api", "admin", "users", principal_id] if method == "GET" => {
                json(self.console_api_user_detail(access_key_id, principal_id)?)
            }
            ["ui", "api", "object-governance"] if method == "GET" => {
                let (bucket, key, version_id) = object_query(request)?;
                json(self.console_api_object_governance_summary(
                    access_key_id,
                    &bucket,
                    &key,
                    version_id.as_deref(),
                )?)
            }
            ["ui", "api", "object-lock-retention"] if method == "GET" => {
                let (bucket, key, version_id) = object_query(request)?;
                json(self.console_api_object_lock_retention(
                    access_key_id,
                    &bucket,
                    &key,
                    version_id.as_deref(),
                )?)
            }
            ["ui", "api", "legal-hold"] if method == "GET" => {
                let (bucket, key, version_id) = object_query(request)?;
                json(self.console_api_legal_hold(
                    access_key_id,
                    &bucket,
                    &key,
                    version_id.as_deref(),
                )?)
            }
            ["ui", "api", "legal-hold"] if method == "POST" => {
                let payload = serde_json::from_value::<ConsoleApiLegalHoldPayload>(
                    request.body.clone().unwrap_or_default(),
                )
                .map_err(RuntimeError::SnapshotDeserialize)?;
                if payload.reason.trim().is_empty() {
                    return Err(RuntimeError::InvalidListParameter {
                        name: "reason".to_string(),
                        value: String::new(),
                    });
                }
                let (bucket, key, version_id) = object_query(request)?;
                json(self.console_api_put_legal_hold(
                    access_key_id,
                    &bucket,
                    &key,
                    version_id.as_deref(),
                    payload.enabled,
                    payload.reason,
                )?)
            }
            ["ui", "api", "object-versions"] if method == "GET" => {
                let (bucket, key, _) = object_query(request)?;
                json(self.console_api_object_version_history(
                    access_key_id,
                    &bucket,
                    &key,
                    list_query(request),
                )?)
            }
            _ => Err(RuntimeError::InvalidToken(format!(
                "unsupported console API route {method} {path}"
            ))),
        }
    }
}

fn canonical_console_api_path(path: &str) -> String {
    if path == "/api/v1" {
        return "/ui/api".to_string();
    }
    path.strip_prefix("/api/v1/")
        .map(|suffix| format!("/ui/api/{suffix}"))
        .unwrap_or_else(|| path.to_string())
}

impl ConsoleApiResponse {
    fn json(status: u16, request_id: String, body: serde_json::Value) -> Self {
        Self {
            status,
            content_type: "application/json".to_string(),
            request_id,
            body,
        }
    }
}

fn json(value: impl Serialize) -> Result<serde_json::Value, RuntimeError> {
    serde_json::to_value(value).map_err(RuntimeError::SnapshotSerialize)
}

fn list_query(request: &ConsoleApiRequest) -> ConsoleApiListQuery {
    ConsoleApiListQuery {
        limit: parse_usize(&request.query, "limit"),
        offset: parse_usize(&request.query, "offset"),
        prefix: request.query.get("prefix").cloned(),
        q: request.query.get("q").cloned(),
        sort: request.query.get("sort").cloned(),
    }
}

fn audit_query(request: &ConsoleApiRequest) -> ConsoleApiAuditQuery {
    ConsoleApiAuditQuery {
        limit: parse_usize(&request.query, "limit"),
        offset: parse_usize(&request.query, "offset"),
        q: request.query.get("q").cloned(),
        sort: request.query.get("sort").cloned(),
        subject: request.query.get("subject").cloned(),
        action: request.query.get("action").cloned(),
        resource: request.query.get("resource").cloned(),
        outcome: request.query.get("outcome").cloned(),
        min_sequence: parse_u64(&request.query, "min_sequence"),
        max_sequence: parse_u64(&request.query, "max_sequence"),
    }
}

fn object_query(
    request: &ConsoleApiRequest,
) -> Result<(String, String, Option<String>), RuntimeError> {
    let bucket = request
        .query
        .get("bucket")
        .cloned()
        .ok_or_else(|| missing_query("bucket"))?;
    let key = request
        .query
        .get("key")
        .cloned()
        .ok_or_else(|| missing_query("key"))?;
    Ok((bucket, key, request.query.get("versionId").cloned()))
}

fn parse_usize(query: &BTreeMap<String, String>, key: &str) -> Option<usize> {
    query.get(key).and_then(|value| value.parse().ok())
}

fn parse_u64(query: &BTreeMap<String, String>, key: &str) -> Option<u64> {
    query.get(key).and_then(|value| value.parse().ok())
}

fn missing_query(key: &str) -> RuntimeError {
    RuntimeError::InvalidListParameter {
        name: key.to_string(),
        value: String::new(),
    }
}