bucketwarden-server 0.1.0

BucketWarden storage server runtime.
Documentation
#![allow(unused_imports)]
mod common;

use bucketwarden_s3::{
    sigv4::{authorization_header, presigned_url_query, sha256_hex, AwsCredentials, SigV4Request},
    S3HttpRequest,
};
use bucketwarden_server::{
    s3_service_specific_error_catalog, BucketWarden, OperatorRole, RuntimeConfig,
};
use common::*;

const HOST: &str = "s3.bucketwarden.test";
const AMZ_DATE: &str = "20130524T000000Z";
const SCOPE_DATE: &str = "20130524";
const NOW: u64 = 1_369_353_620;

#[test]
fn http_sigv4_surface_reports_specific_s3_general_error_codes() {
    let mut runtime = BucketWarden::new(RuntimeConfig::development()).expect("runtime");
    runtime.set_clock_epoch_seconds(NOW);
    runtime.allow("alice", "s3:*", "*");
    runtime.create_local_user("alice");
    runtime
        .create_access_key("alice", "AKIAOK", "ok-secret")
        .expect("ok key");
    runtime
        .create_access_key_with_expiry("alice", "AKIAEXPIRED", "expired-secret", NOW - 1)
        .expect("expired key");
    runtime
        .create_session_credential(
            "alice",
            "AKIAOK",
            "ASIATEMP",
            "session-secret",
            "session-token",
            NOW + 60,
        )
        .expect("session");
    runtime
        .handle_s3_http(S3HttpRequest::new("alice", "PUT", "/archive-001"))
        .expect("bucket");

    let malformed_query = runtime
        .handle_s3_http(
            S3HttpRequest::new("anonymous", "GET", "/archive-001")
                .with_header("host", HOST)
                .with_query("X-Amz-Signature", "abc123"),
        )
        .expect("malformed query");
    assert_labeled_error_response(
        "malformed_query",
        &malformed_query,
        400,
        "AuthorizationQueryParametersError",
        false,
    );

    let malformed_header = runtime
        .handle_s3_http(
            S3HttpRequest::new("anonymous", "GET", "/archive-001")
                .with_header("host", HOST)
                .with_header(
                    "authorization",
                    "AWS4-HMAC-SHA256 SignedHeaders=host;x-amz-date, Signature=abc123",
                )
                .with_header("x-amz-date", AMZ_DATE)
                .with_header("x-amz-content-sha256", sha256_hex(b"")),
        )
        .expect("malformed authorization");
    assert_labeled_error_response(
        "malformed_header",
        &malformed_header,
        400,
        "AuthorizationHeaderMalformed",
        false,
    );

    let invalid_access_key = runtime
        .handle_s3_http(signed_get(
            "/archive-001",
            &AwsCredentials::new("AKIAUNKNOWN", "unknown-secret"),
        ))
        .expect("invalid access key");
    assert_labeled_error_response(
        "invalid_access_key",
        &invalid_access_key,
        403,
        "InvalidAccessKeyId",
        false,
    );

    let expired = runtime
        .handle_s3_http(signed_get(
            "/archive-001",
            &AwsCredentials::new("AKIAEXPIRED", "expired-secret"),
        ))
        .expect("expired access key");
    assert_labeled_error_response("expired", &expired, 400, "ExpiredToken", false);

    let credentials = AwsCredentials::session("ASIATEMP", "session-secret", "session-token");
    let bad_token = runtime
        .handle_s3_http(signed_get_with_session_token(
            "/archive-001",
            &credentials,
            "wrong-token",
        ))
        .expect("bad token");
    assert_labeled_error_response("bad_token", &bad_token, 400, "InvalidToken", false);

    let base = SigV4Request::new("GET", "/archive-001")
        .with_header("host", HOST)
        .with_payload_hash("UNSIGNED-PAYLOAD");
    let mut invalid_timestamp =
        S3HttpRequest::new("anonymous", "GET", "/archive-001").with_header("host", HOST);
    for (key, value) in presigned_url_query(
        &base,
        &AwsCredentials::new("AKIAOK", "ok-secret"),
        AMZ_DATE,
        SCOPE_DATE,
        "us-east-1",
        "s3",
        60,
        &["host"],
    )
    .expect("presign")
    {
        let value = if key == "X-Amz-Date" {
            "not-a-timestamp".to_string()
        } else {
            value
        };
        invalid_timestamp = invalid_timestamp.with_query(key, value);
    }
    let invalid_timestamp = runtime
        .handle_s3_http(invalid_timestamp)
        .expect("invalid timestamp");
    assert_labeled_error_response(
        "invalid_timestamp",
        &invalid_timestamp,
        403,
        "RequestTimeTooSkewed",
        false,
    );
}

#[test]
fn s3_root_access_denied_error_has_matching_content_length() {
    let mut runtime = BucketWarden::new(RuntimeConfig::development()).expect("runtime");
    runtime.allow("alice", "s3:*", "*");
    runtime
        .handle_s3_http(S3HttpRequest::new("alice", "PUT", "/archive-001"))
        .expect("bucket");

    let anonymous = runtime
        .handle_s3_http(S3HttpRequest::new("anonymous", "GET", "/"))
        .expect("root access denied");
    let body = String::from_utf8(anonymous.body.clone()).expect("xml");

    assert_eq!(anonymous.status, 403);
    assert_eq!(
        anonymous.headers.get("content-type"),
        Some(&"application/xml".to_string())
    );
    assert_eq!(
        anonymous.headers.get("content-length"),
        Some(&anonymous.body.len().to_string())
    );
    assert!(body.starts_with("<Error>"));
    assert!(body.ends_with("</Error>"));
    assert!(body.contains("<Code>AccessDenied</Code>"));
    assert!(body.contains("<RequestId>"));
    assert!(body.contains("<HostId>"));

    let authorized = runtime
        .handle_s3_http(S3HttpRequest::new("alice", "GET", "/"))
        .expect("root list buckets");
    let authorized_body = String::from_utf8(authorized.body.clone()).expect("xml");
    assert_eq!(authorized.status, 200);
    assert_eq!(
        authorized.headers.get("content-type"),
        Some(&"application/xml".to_string())
    );
    assert!(authorized_body.starts_with("<ListAllMyBucketsResult>"));
    assert!(authorized_body.contains("<Name>archive-001</Name>"));
    assert!(!authorized_body.contains("<!doctype html"));
    assert!(!authorized_body.contains("<html"));
}

fn assert_labeled_error_response(
    label: &str,
    response: &bucketwarden_s3::S3HttpResponse,
    status: u16,
    code: &str,
    retryable: bool,
) {
    let body = String::from_utf8(response.body.clone()).expect("xml");
    assert_eq!(
        response.status, status,
        "{label}: unexpected status with body {body}"
    );
    assert_eq!(
        response.headers.get("x-bucketwarden-error-retryable"),
        Some(&retryable.to_string()),
        "{label}: unexpected retryability with body {body}"
    );
    assert!(
        body.contains(&format!("<Code>{code}</Code>")),
        "{label}: expected code {code} but body was {body}"
    );
    assert!(
        body.contains("<RequestId>bwreq"),
        "{label}: missing request id in body {body}"
    );
    assert!(
        body.contains("<HostId>"),
        "{label}: missing host id in body {body}"
    );
}

fn signed_authorization(path: &str, credentials: &AwsCredentials, amz_date: &str) -> String {
    let payload_hash = sha256_hex(b"");
    let request = SigV4Request::new("GET", path)
        .with_header("host", HOST)
        .with_header("x-amz-date", amz_date)
        .with_header("x-amz-content-sha256", payload_hash.clone())
        .with_payload_hash(payload_hash);
    authorization_header(
        &request,
        credentials,
        amz_date,
        SCOPE_DATE,
        "us-east-1",
        "s3",
        &["host", "x-amz-content-sha256", "x-amz-date"],
    )
    .expect("authorization")
}

fn signed_get(path: &str, credentials: &AwsCredentials) -> S3HttpRequest {
    let payload_hash = sha256_hex(b"");
    S3HttpRequest::new("anonymous", "GET", path)
        .with_header("host", HOST)
        .with_header("x-amz-date", AMZ_DATE)
        .with_header("x-amz-content-sha256", payload_hash)
        .with_header(
            "authorization",
            signed_authorization(path, credentials, AMZ_DATE),
        )
}

fn signed_get_with_session_token(
    path: &str,
    credentials: &AwsCredentials,
    provided_token: &str,
) -> S3HttpRequest {
    let payload_hash = sha256_hex(b"");
    let request = SigV4Request::new("GET", path)
        .with_header("host", HOST)
        .with_header("x-amz-date", AMZ_DATE)
        .with_header("x-amz-content-sha256", payload_hash.clone())
        .with_header("x-amz-security-token", provided_token)
        .with_payload_hash(payload_hash.clone());
    let authorization = authorization_header(
        &request,
        credentials,
        AMZ_DATE,
        SCOPE_DATE,
        "us-east-1",
        "s3",
        &[
            "host",
            "x-amz-content-sha256",
            "x-amz-date",
            "x-amz-security-token",
        ],
    )
    .expect("authorization");
    S3HttpRequest::new("anonymous", "GET", path)
        .with_header("host", HOST)
        .with_header("x-amz-date", AMZ_DATE)
        .with_header("x-amz-content-sha256", payload_hash)
        .with_header("x-amz-security-token", provided_token)
        .with_header("authorization", authorization)
}

#[test]
fn http_method_not_allowed_errors_are_general_family_at_service_bucket_and_object_layers() {
    let mut runtime = runtime();
    runtime
        .handle_s3_http(S3HttpRequest::new("alice", "PUT", "/archive-001"))
        .expect("create bucket");
    runtime
        .handle_s3_http(
            S3HttpRequest::new("alice", "PUT", "/archive-001/readonly.txt")
                .with_body(b"immutable".to_vec()),
        )
        .expect("put object");

    let service = runtime
        .handle_s3_http(S3HttpRequest::new("alice", "PATCH", "/"))
        .expect("service method not allowed");
    assert_error_response_with_family(&service, 405, "MethodNotAllowed", "general", false);

    let bucket = runtime
        .handle_s3_http(S3HttpRequest::new("alice", "PATCH", "/archive-001"))
        .expect("bucket method not allowed");
    assert_error_response_with_family(&bucket, 405, "MethodNotAllowed", "general", false);

    let object = runtime
        .handle_s3_http(S3HttpRequest::new(
            "alice",
            "PATCH",
            "/archive-001/readonly.txt",
        ))
        .expect("object method not allowed");
    assert_error_response_with_family(&object, 405, "MethodNotAllowed", "general", false);
}

#[test]
fn runtime_service_s3err_queries_cover_all_general_features_t0_grade() {
    let mut runtime = runtime();
    runtime.create_local_user("alice");
    runtime
        .assign_operator_role("alice", "alice", OperatorRole::ClusterAdmin, "*")
        .expect("assign cluster-admin");

    let general_features: Vec<_> = s3_service_specific_error_catalog()
        .iter()
        .filter(|error| error.family == "general")
        .collect();
    assert!(!general_features.is_empty(), "no general features found");

    for chunk in general_features.chunks(25) {
        for feature in chunk {
            let response = runtime
                .handle_s3_http(
                    S3HttpRequest::new("alice", "GET", "/")
                        .with_query("x-bucketwarden-s3err-feature", feature.feature_id),
                )
                .expect("query-driven service-specific error");
            assert_error_response_with_family(
                &response,
                feature.status,
                feature.code,
                feature.family,
                feature.status == 500 || feature.status == 503,
            );
        }
    }

    let code_query = runtime
        .handle_s3_http(
            S3HttpRequest::new("alice", "GET", "/")
                .with_query("x-bucketwarden-s3err-code", "NoSuchBucket")
                .with_query("x-bucketwarden-s3err-family", "general"),
        )
        .expect("code/family query");
    assert_error_response_with_family(&code_query, 404, "NoSuchBucket", "general", false);

    let empty_feature = runtime
        .handle_s3_http(
            S3HttpRequest::new("alice", "GET", "/").with_query("x-bucketwarden-s3err-feature", ""),
        )
        .expect("empty-feature query");
    assert_error_response(&empty_feature, 400, "InvalidRequest", false);

    let unknown_feature = runtime
        .handle_s3_http(S3HttpRequest::new("alice", "GET", "/").with_query(
            "x-bucketwarden-s3err-feature",
            "feat:bucketwarden.s3err.general.dne",
        ))
        .expect("unknown feature");
    assert_error_response(&unknown_feature, 400, "InvalidRequest", false);
}

#[test]
fn runtime_service_s3err_query_requires_operator_read_diagnostics_role() {
    let mut runtime = runtime();
    let denied = runtime
        .handle_s3_http(S3HttpRequest::new("alice", "GET", "/").with_query(
            "x-bucketwarden-s3err-feature",
            "feat:bucketwarden.s3err.general.nosuchbucket",
        ))
        .expect("operator denied");
    assert_error_response(&denied, 403, "AccessDenied", false);

    runtime.create_local_user("alice");
    runtime
        .assign_operator_role("alice", "alice", OperatorRole::ClusterAdmin, "*")
        .expect("assign cluster-admin");
    let allowed = runtime
        .handle_s3_http(S3HttpRequest::new("alice", "GET", "/").with_query(
            "x-bucketwarden-s3err-feature",
            "feat:bucketwarden.s3err.general.nosuchbucket",
        ))
        .expect("operator allowed");
    assert_error_response(&allowed, 404, "NoSuchBucket", false);
}