Skip to content
Draft
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
519 changes: 416 additions & 103 deletions Cargo.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ aes-gcm = "0.10"
object_store = { version = "0.13.1", default-features = false, features = ["aws"] }
futures = "0.3"

# S3 protocol
s3s = { version = "0.13.0", default-features = false }
dashmap = "6"

# XML
quick-xml = { version = "0.37", features = ["serialize"] }

Expand Down
8 changes: 2 additions & 6 deletions crates/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,10 @@ serde_json.workspace = true
bytes.workspace = true
http.workspace = true
chrono.workspace = true
uuid.workspace = true
base64.workspace = true
hex.workspace = true
url.workspace = true
hmac.workspace = true
sha2.workspace = true
quick-xml.workspace = true
tracing.workspace = true
object_store.workspace = true
futures.workspace = true
matchit.workspace = true
s3s.workspace = true
dashmap.workspace = true
102 changes: 38 additions & 64 deletions crates/core/README.md
Original file line number Diff line number Diff line change
@@ -1,94 +1,74 @@
# multistore

Runtime-agnostic core library for the S3 proxy gateway. This crate contains all business logic — S3 request parsing, SigV4 signing/verification, authorization, configuration retrieval, and the proxy handler — without depending on any async runtime.
Runtime-agnostic core library for the S3 proxy gateway. This crate provides an s3s-based S3 service implementation that maps S3 API operations to `object_store` calls, along with trait abstractions for bucket/credential management that allow the proxy to run on multiple runtimes (Tokio/Hyper, AWS Lambda, Cloudflare Workers).

## Why This Crate Exists Separately

The proxy needs to run on fundamentally different runtimes: Tokio/Hyper in containers and Cloudflare Workers on the edge. These runtimes have incompatible stream types, HTTP primitives, and threading models (multi-threaded vs single-threaded WASM). By keeping the core free of runtime dependencies, it compiles cleanly to both `x86_64-unknown-linux-gnu` and `wasm32-unknown-unknown`.
The proxy needs to run on fundamentally different runtimes: Tokio/Hyper in containers, AWS Lambda, and Cloudflare Workers on the edge. These runtimes have incompatible stream types, HTTP primitives, and threading models (multi-threaded vs single-threaded WASM). By keeping the core free of runtime dependencies, it compiles cleanly to both native targets and `wasm32-unknown-unknown`.

## Key Abstractions

The core defines three trait boundaries that runtime crates implement:
**`MultistoreService`** — Implements the `s3s::S3` trait, mapping S3 operations (GET, PUT, DELETE, LIST, multipart uploads) to `object_store` calls. Generic over `BucketRegistry` (for bucket lookup/authorization) and `StoreFactory` (for creating object stores per request).

**`ProxyBackend`** — Provides three capabilities: `create_paginated_store()` returns a `PaginatedListStore` for LIST, `create_signer()` returns a `Signer` for presigned URL generation (GET/HEAD/PUT/DELETE), and `send_raw()` sends signed HTTP requests for multipart operations. Both runtimes delegate to `build_signer()` which uses `object_store`'s built-in signer for authenticated backends and `UnsignedUrlSigner` for anonymous backends (avoiding `Instant::now()` which panics on WASM). For `create_paginated_store()`, the server runtime uses default connectors + reqwest; the worker runtime uses a custom `FetchConnector`.
**`MultistoreAuth`** — Implements `s3s::auth::S3Auth`, wrapping a `CredentialRegistry` to provide SigV4 verification. s3s handles signature parsing and verification; this adapter just looks up secret keys.

**`BucketRegistry`** — Identity-aware bucket resolution and listing. Given a bucket name, identity, and S3 operation, `get_bucket()` returns a `ResolvedBucket` (config + optional list rewrite) or an authorization error. `list_buckets()` returns the buckets visible to a given identity. See `multistore-static-config` for a file-based implementation.
**`StoreFactory`** — Runtime-provided factory for creating `ObjectStore`, `PaginatedListStore`, and `MultipartStore` instances. Each runtime implements this trait with its own HTTP transport (e.g., reqwest for native, `FetchConnector` for Workers).

**`CredentialRegistry`** — Credential and role lookup for authentication infrastructure. Provides `get_credential()` for SigV4 verification and `get_role()` for STS role assumption. See `multistore-static-config` for a file-based implementation.
**`BucketRegistry`** — Identity-aware bucket resolution and listing. Given a bucket name, identity, and S3 operation, `get_bucket()` returns a `ResolvedBucket` or an authorization error.

Any provider implementing these traits can be wrapped with `CachedProvider` for in-memory TTL caching of credential/role lookups (bucket resolution is always delegated directly since it involves authorization).
**`CredentialRegistry`** — Credential and role lookup for authentication. Provides `get_credential()` for SigV4 verification and `get_role()` for STS role assumption.

The core also defines the **`Middleware`** trait for composable post-auth request processing. Middleware runs after identity resolution and authorization, wrapping the backend dispatch call. Each middleware receives a `DispatchContext` and a `Next` handle to continue the chain. The `oidc-provider` crate provides `AwsBackendAuth` as a middleware that resolves backend credentials via OIDC token exchange.
**`StoreBuilder`** — Provider-specific `object_store` builder (S3, Azure, GCS). Runtimes call `create_builder()` to get a half-built store, customize it (e.g., inject an HTTP connector), then call one of the `build_*` methods.

## Module Overview

```
src/
├── api/
│ ├── request.rs Parse incoming HTTP → S3Operation enum
│ ├── response.rs Serialize S3 XML responses
│ └── list_rewrite.rs Rewrite <Key>/<Prefix> values in list response XML
│ ├── response.rs S3 XML response serialization
│ ├── list.rs LIST-specific helpers (prefix building)
│ └── list_rewrite.rs Rewrite <Key>/<Prefix> values for backend prefix mapping
├── auth/
│ ├── mod.rs Authorization (scope-based access control)
│ ├── identity.rs SigV4 verification, identity resolution
│ └── tests.rs Auth test helpers
│ ├── mod.rs TemporaryCredentialResolver trait
│ └── authorize_impl.rs Scope-based authorization (identity × operation × bucket)
├── backend/
│ └── mod.rs ProxyBackend trait, Signer/StoreBuilder, S3RequestSigner (multipart)
│ ├── mod.rs StoreBuilder, create_builder()
│ └── url_signer.rs build_signer() helper
├── registry/
│ ├── mod.rs Re-exports
│ ├── bucket.rs BucketRegistry trait, ResolvedBucket, DEFAULT_BUCKET_OWNER
│ └── credential.rs CredentialRegistry trait
├── error.rs ProxyError with S3-compatible error codes
├── middleware.rs Middleware trait, DispatchContext, Next
├── proxy.rs ProxyGateway — the main request handler
├── route_handler.rs RouteHandler trait, ProxyResponseBody
└── types.rs BucketConfig, RoleConfig, StoredCredential, etc.
│ ├── bucket.rs BucketRegistry trait, ResolvedBucket
│ └── credential.rs CredentialRegistry trait
├── error.rs ProxyError with S3-compatible error codes
├── maybe_send.rs MaybeSend/MaybeSync markers for WASM compatibility
├── service.rs MultistoreService, MultistoreAuth, StoreFactory
└── types.rs BucketConfig, RoleConfig, StoredCredential, etc.
```

## Usage

This crate is not used directly. Runtime crates depend on it and provide concrete `ProxyBackend` implementations. If you're building a custom runtime integration, depend on this crate and implement `ProxyBackend`, `BucketRegistry`, and/or `CredentialRegistry`.
This crate is not used directly. Runtime crates depend on it and provide concrete `StoreFactory` implementations.

### Standard usage
### Standard usage (s3s)

```rust
use multistore::proxy::ProxyGateway;
use multistore::service::{MultistoreService, MultistoreAuth};
use multistore_static_config::StaticProvider;
use s3s::service::S3ServiceBuilder;

let backend = MyBackend::new();
let backend = MyStoreFactory::new();
let config = StaticProvider::from_file("config.toml")?;

let gateway = ProxyGateway::new(
backend,
config.clone(), // as BucketRegistry
config, // as CredentialRegistry
Some("s3.example.com".into()),
);
// Optional: enable session token verification for STS temporary credentials.
// let gateway = gateway.with_credential_resolver(token_key);
// Optional: register route handlers for STS, OIDC discovery, etc.
// let gateway = gateway.with_route_handler(sts_handler);
// Optional: register middleware (e.g., OIDC-based backend credential resolution).
// let gateway = gateway.with_middleware(oidc_auth);

// In your HTTP handler, use handle_request for a two-variant match:
let req_info = RequestInfo {
method: &method,
path: &path,
query: query.as_deref(),
headers: &headers,
source_ip: None,
params: Default::default(),
};
match gateway.handle_request(&req_info, body, |b| to_bytes(b)).await {
GatewayResponse::Response(result) => {
// Return the complete response (LIST, errors, STS, etc.)
}
GatewayResponse::Forward(fwd, body) => {
// Execute presigned URL with your HTTP client
// Stream request body (PUT) or response body (GET)
}
}
let service = MultistoreService::new(config.clone(), backend);
let auth = MultistoreAuth::new(config);

let mut builder = S3ServiceBuilder::new(service);
builder.set_auth(auth);

// Optional: set virtual-hosted-style domain
// builder.set_host(s3s::host::SingleDomain::new("s3.example.com")?);

let s3_service = builder.build();

// Use s3_service with hyper, lambda_http, or call directly
```

### Custom BucketRegistry
Expand Down Expand Up @@ -123,12 +103,6 @@ impl BucketRegistry for MyBucketRegistry {
}
```

## Temporary Credential Resolution

The core defines a `TemporaryCredentialResolver` trait for resolving session tokens (from `x-amz-security-token`) into `TemporaryCredentials`. The core proxy calls this during identity resolution without knowing the token format.

The `multistore-sts` crate provides `TokenKey`, a sealed-token implementation using AES-256-GCM. Register it via `ProxyGateway::with_credential_resolver()`. See the [sealed tokens documentation](../docs/auth/sealed-tokens.md) for details.

## Feature Flags

All optional — the default build has zero network dependencies:
Expand Down
167 changes: 2 additions & 165 deletions crates/core/src/api/list.rs
Original file line number Diff line number Diff line change
@@ -1,68 +1,8 @@
//! LIST-specific helpers for building S3 ListObjectsV2 XML responses.
//!
//! Extracted from `proxy.rs` to keep the gateway focused on orchestration.
//! LIST-specific helpers.

use crate::api::list_rewrite::ListRewrite;
use crate::api::response::{ListBucketResult, ListCommonPrefix, ListContents};
use crate::error::ProxyError;
use crate::types::BucketConfig;

/// Parameters for building the S3 ListObjectsV2 XML response.
pub(crate) struct ListXmlParams<'a> {
pub bucket_name: &'a str,
pub client_prefix: &'a str,
pub delimiter: &'a str,
pub max_keys: usize,
pub is_truncated: bool,
pub key_count: usize,
pub start_after: &'a Option<String>,
pub continuation_token: &'a Option<String>,
pub next_continuation_token: Option<String>,
}

/// All query parameters needed for a LIST operation, parsed in a single pass.
pub(crate) struct ListQueryParams {
pub prefix: String,
pub delimiter: String,
pub max_keys: usize,
pub continuation_token: Option<String>,
pub start_after: Option<String>,
}

/// Parse prefix, delimiter, and pagination params from a LIST query string in one pass.
pub(crate) fn parse_list_query_params(raw_query: Option<&str>) -> ListQueryParams {
let mut prefix = None;
let mut delimiter = None;
let mut max_keys = None;
let mut continuation_token = None;
let mut start_after = None;

if let Some(q) = raw_query {
for (k, v) in url::form_urlencoded::parse(q.as_bytes()) {
match k.as_ref() {
"prefix" => prefix = Some(v.into_owned()),
"delimiter" => delimiter = Some(v.into_owned()),
"max-keys" => max_keys = Some(v.into_owned()),
"continuation-token" => continuation_token = Some(v.into_owned()),
"start-after" => start_after = Some(v.into_owned()),
_ => {}
}
}
}

ListQueryParams {
prefix: prefix.unwrap_or_default(),
delimiter: delimiter.unwrap_or_else(|| "/".to_string()),
max_keys: max_keys
.and_then(|v| v.parse().ok())
.unwrap_or(1000)
.min(1000),
continuation_token,
start_after,
}
}

/// Build the full list prefix including backend_prefix.
/// Build the full backend prefix by prepending `backend_prefix` (if set).
pub(crate) fn build_list_prefix(config: &BucketConfig, client_prefix: &str) -> String {
match &config.backend_prefix {
Some(prefix) => {
Expand All @@ -80,106 +20,3 @@ pub(crate) fn build_list_prefix(config: &BucketConfig, client_prefix: &str) -> S
None => client_prefix.to_string(),
}
}

/// Build S3 ListObjectsV2 XML from an object_store ListResult.
///
/// Pagination is handled by the backend — `is_truncated` and
/// `next_continuation_token` are passed through from the backend's response.
pub(crate) fn build_list_xml(
params: &ListXmlParams<'_>,
list_result: &object_store::ListResult,
config: &BucketConfig,
list_rewrite: Option<&ListRewrite>,
) -> Result<String, ProxyError> {
let backend_prefix = config
.backend_prefix
.as_deref()
.unwrap_or("")
.trim_end_matches('/');
let strip_prefix = if backend_prefix.is_empty() {
String::new()
} else {
format!("{}/", backend_prefix)
};

let contents: Vec<ListContents> = list_result
.objects
.iter()
.map(|obj| {
let raw_key = obj.location.to_string();
ListContents {
key: rewrite_key(&raw_key, &strip_prefix, list_rewrite),
last_modified: obj
.last_modified
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
.to_string(),
etag: obj.e_tag.as_deref().unwrap_or("\"\"").to_string(),
size: obj.size,
storage_class: "STANDARD",
}
})
.collect();

let common_prefixes: Vec<ListCommonPrefix> = list_result
.common_prefixes
.iter()
.map(|p| {
let raw_prefix = format!("{}/", p);
ListCommonPrefix {
prefix: rewrite_key(&raw_prefix, &strip_prefix, list_rewrite),
}
})
.collect();

Ok(ListBucketResult {
xmlns: "http://s3.amazonaws.com/doc/2006-03-01/",
name: params.bucket_name.to_string(),
prefix: params.client_prefix.to_string(),
delimiter: params.delimiter.to_string(),
max_keys: params.max_keys,
is_truncated: params.is_truncated,
key_count: params.key_count,
start_after: params.start_after.clone(),
continuation_token: params.continuation_token.clone(),
next_continuation_token: params.next_continuation_token.clone(),
contents,
common_prefixes,
}
.to_xml())
}

/// Apply strip/add prefix rewriting to a key or prefix value.
///
/// Works with `&str` slices to avoid intermediate allocations — only allocates
/// the final `String` once.
fn rewrite_key(raw: &str, strip_prefix: &str, list_rewrite: Option<&ListRewrite>) -> String {
// Strip the backend prefix (borrow from `raw`, no allocation)
let key = if !strip_prefix.is_empty() {
raw.strip_prefix(strip_prefix).unwrap_or(raw)
} else {
raw
};

// Apply list_rewrite if present
if let Some(rewrite) = list_rewrite {
let key = if !rewrite.strip_prefix.is_empty() {
key.strip_prefix(rewrite.strip_prefix.as_str())
.unwrap_or(key)
} else {
key
};

if !rewrite.add_prefix.is_empty() {
// Must allocate for add_prefix — early return
return if key.is_empty() || key.starts_with('/') {
format!("{}{}", rewrite.add_prefix, key)
} else {
format!("{}/{}", rewrite.add_prefix, key)
};
}

return key.to_string();
}

key.to_string()
}
1 change: 0 additions & 1 deletion crates/core/src/api/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
pub(crate) mod list;
pub mod list_rewrite;
pub mod request;
pub mod response;
Loading
Loading