Skip to content
Merged
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
461 changes: 397 additions & 64 deletions Cargo.lock

Large diffs are not rendered by default.

27 changes: 26 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,47 @@
resolver = "2"
members = [
"components/client",
"components/gate",
"components/gate-client",
"components/gate-handler",
"components/latch-defer-all",
"components/latch-delegate-client",
"components/latch-delegate-handler",
"components/latch-deny-all",
"components/latch-deny-random",
"components/latch-dry-run",
"components/latch-method",
"components/latch-method-readonly",
"components/latch-n2",
"components/latch-n3",
"components/latch-n4",
"components/latch-n5",
"components/latch-scheme",
"components/latch-scheme-httpsonly",
"components/latch-trace",
"components/trace",
"components/trace-client",
"components/trace-handler",
"components/trace-componentized-client",
"components/trace-handler",
"components/trace-types",
"crates/http-latch",
"crates/http-latch-n",
"crates/http-utils",
"crates/test-harness",
]

[workspace.dependencies]
bytes = "1"
http = "1"
http-body-util = "0.1"
http-latch = { path = "./crates/http-latch" }
http-latch-n = { path = "./crates/http-latch-n" }
http-utils = { path = "./crates/http-utils" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
test-harness = { path = "./crates/test-harness" }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] }
wac-graph = "0.11"
wasmtime = { version = "49", features = ["component-model-async"] }
wasmtime-wasi = { version = "49", features = ["p3"] }
wasmtime-wasi-http = { version = "49", default-features = false, features = ["p3", "component-model-async"] }
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ wit: $(WIT_DEPS)
define FETCH_WIT

# a package overridden with a local path, e.g. `{ path = "../wit" }`, has its dependencies fetched first
$(call wit_deps,$1): $1/wkg.toml $(shell find $1/wit -type f -name "*.wit" -not -path "*/deps/*") $(foreach path,$(shell sed -n 's/.*path *= *"\(.*\)".*/\1/p' $1/wkg.toml),$(call relpath,$1/$(path))/deps) | $(call tool,wkg)
$(call wit_deps,$1): $1/wkg.toml $1/wkg.lock $(shell find $1/wit -type f -name "*.wit" -not -path "*/deps/*") $(foreach path,$(shell sed -n 's/.*path *= *"\(.*\)".*/\1/p' $1/wkg.toml),$(call relpath,$1/$(path))/deps) | $(call tool,wkg)
$(if $(filter .,$1),,cd $1 && )wkg fetch

endef
Expand Down
70 changes: 63 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
A collection of utility components that remix wasi:http types and interfaces.

- [Components](#components)
- [Gates](#gates)
- [Latches](#latches)
- [Blanket decisions](#blanket-decisions)
- [Request properties](#request-properties)
- [Fault injection](#fault-injection)
- [Combining latches](#combining-latches)
- [Tracing](#tracing)
- [Choosing a component variant](#choosing-a-component-variant)
- [Build](#build)
- [Community](#community)
Expand All @@ -15,13 +22,62 @@ A collection of utility components that remix wasi:http types and interfaces.

## Components

- [`client`](./components/client/)
- [`status-codes`](./components/status-codes/)
- [`trace`](./components/trace/)
- [`trace-client`](./components/trace-client/)
- [`trace-handler`](./components/trace-handler/)
- [`trace-componentized-client`](./components/trace-componentized-client/)
- [`trace-types`](./components/trace-types/)
- [`client`](./components/client/): a higher-level HTTP client that delegates to `wasi:http/client`
- [`status-codes`](./components/status-codes/): constants for HTTP status codes

### Gates

Access control for wasi:http is split between gates and latches. A gate wraps `wasi:http/client` or `wasi:http/handler` and consults a latch before each request, a latch decides whether the request may proceed. Latches are small and single purpose, combine them to build a policy.

- [`gate`](./components/gate/): gates both `wasi:http/client` and `wasi:http/handler`
- [`gate-client`](./components/gate-client/): gates `wasi:http/client`, requests sent
- [`gate-handler`](./components/gate-handler/): gates `wasi:http/handler`, requests handled

A denied request fails with the latch's reason, and is logged as a warning. A latch error fails the request with `internal-error`, and is logged as an error.

> [!CAUTION]
> Interfering with HTTP requests can have dramatic, unintended consequences. A denied request surfaces to the caller as a failed request, which can trigger retries, timeouts and fallbacks far from the request that was denied. Install new latches, and new configurations of existing latches, cautiously and monitor the result: roll out with [`latch-dry-run`](./components/latch-dry-run/), watch decisions with [`latch-trace`](./components/latch-trace/), and review the denials the gate logs.

### Latches

Decide which requests are allowed. A latch defers or denies, a request proceeds unless a latch denies it. Deciding and acting on a decision are separate steps, a latch is told the final decision for each request with `observe-decision`.

#### Blanket decisions

- [`latch-defer-all`](./components/latch-defer-all/): defers every request, allowing all requests
- [`latch-deny-all`](./components/latch-deny-all/): denies every request

#### Request properties

- [`latch-method`](./components/latch-method/): decides by the request method, configured with `wasi:config/store`
- [`latch-method-readonly`](./components/latch-method-readonly/): allows only GET, HEAD, QUERY and OPTIONS, `latch-method` with [`latch-method-readonly-config`](./components/latch-method-readonly-config/)
- [`latch-scheme`](./components/latch-scheme/): decides by the request scheme, configured with `wasi:config/store`
- [`latch-scheme-httpsonly`](./components/latch-scheme-httpsonly/): allows only HTTPS, `latch-scheme` with [`latch-scheme-httpsonly-config`](./components/latch-scheme-httpsonly-config/)

#### Fault injection

Deny requests on purpose, to prove a component is resilient to failures.

- [`latch-deny-random`](./components/latch-deny-random/): randomly denies a configurable fraction of requests, reproducible with a seed

#### Combining latches

Build a policy from several latches, apply a latch to only part of the requests, or try a policy before enforcing it.

- [`latch-n2`](./components/latch-n2/), [`latch-n3`](./components/latch-n3/), [`latch-n4`](./components/latch-n4/), [`latch-n5`](./components/latch-n5/): aggregate two to five latches, any latch can deny a request
- [`latch-delegate-client`](./components/latch-delegate-client/) / [`latch-delegate-handler`](./components/latch-delegate-handler/): apply a wrapped latch to only `wasi:http/client` or only `wasi:http/handler` requests
- [`latch-dry-run`](./components/latch-dry-run/): log what a wrapped latch would deny without enforcing it, to roll out a policy
- [`latch-trace`](./components/latch-trace/): log the decisions of a wrapped latch

### Tracing

Log wasi:http calls, for debugging or auditing, without affecting them.

- [`trace`](./components/trace/): traces `wasi:http/types`, `wasi:http/client` and `wasi:http/handler`
- [`trace-client`](./components/trace-client/): traces `wasi:http/types` and `wasi:http/client`
- [`trace-handler`](./components/trace-handler/): traces `wasi:http/types` and `wasi:http/handler`
- [`trace-types`](./components/trace-types/): traces `wasi:http/types`
- [`trace-componentized-client`](./components/trace-componentized-client/): traces `componentized:http/client`

### Choosing a component variant

Expand Down
18 changes: 18 additions & 0 deletions components/gate-client/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[package]
name = "gate-client"
version = "0.1.0"
edition = "2024"
license = "Apache-2.0"

[lib]
crate-type = ["cdylib"]

[dependencies]
http-utils = { workspace = true }
wit-bindgen = { workspace = true }

[dev-dependencies]
http = { workspace = true }
test-harness = { workspace = true }
tokio = { workspace = true }
wasmtime = { workspace = true }
28 changes: 28 additions & 0 deletions components/gate-client/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# `gate-client`

HTTP gate access control for requests sent with `wasi:http/client`.

The latch is asked to authorize each request, then told the final decision with `observe-decision`. A denied request fails with the latch's reason and is logged as a warning:

```
Denied REASON=http-request-denied OPERATION=wasi:http/client#send METHOD=post PATH-WITH-QUERY=some</items>
```

A latch error, or a failure to observe the decision, fails the request with `internal-error` and is logged as an error:

```
Latch error CODE=invalid-config<latch-method> OPERATION=wasi:http/client#send METHOD=get PATH-WITH-QUERY=some</>
```

## Interfaces

Imports:

- `componentized:http/latch@0.1.0-dev`: decides whether each request may proceed
- `wasi:logging/logging@0.1.0-draft`: logs denials and latch errors
- `wasi:http/types@0.3.0`: the requests being gated
- `wasi:http/client@0.3.0`: the client the gated client wraps

Exports:

- `wasi:http/client@0.3.0`: client gated by the latch
148 changes: 148 additions & 0 deletions components/gate-client/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
use std::fmt::{self, Display};

use crate::{
componentized::http::latch::{
self, ClientOperation,
Decision::{self, Deferred, Denied},
ErrorCode, HttpErrorCode, Operation, SendArgs,
},
exports::wasi::http::client::{Guest, Request, Response},
wasi::{
http::{client, types},
logging::logging::{Level, log},
},
};

macro_rules! warn {
($dst:expr, $($arg:tt)*) => {
log(Level::Warn, "componentized-gate", &format!($dst, $($arg)*));
};
($dst:expr) => {
log(Level::Warn, "componentized-gate", &format!($dst));
};
}

macro_rules! error {
($dst:expr, $($arg:tt)*) => {
log(Level::Error, "componentized-gate", &format!($dst, $($arg)*));
};
($dst:expr) => {
log(Level::Error, "componentized-gate", &format!($dst));
};
}

/// Authorize the request with the latch, then report the decision back to the latch. Latches act
/// on the final decision in `observe-decision`, never while authorizing. Failing to observe the
/// decision fails the request, the same as a latch error.
fn authorize(operation: &Operation) -> Result<Decision, ErrorCode> {
let decision = latch::authorize(operation)?;
latch::observe_decision(&decision, operation)?;
Ok(decision)
}

struct GatedHttpClient {}

impl Guest for GatedHttpClient {
#[doc = "/ This function may be used to either send an outgoing request over the"]
#[doc = "/ network or to forward it to another component."]
#[allow(async_fn_in_trait)]
async fn send(request: Request) -> Result<Response, HttpErrorCode> {
let call_summary = || {
format!(
"OPERATION=wasi:http/client#send METHOD={method} PATH-WITH-QUERY={path_with_query}",
method = request.get_method(),
path_with_query = DisplayOption(request.get_path_with_query()),
)
};

match authorize(&Operation::Client(ClientOperation::Send(SendArgs {
request: &request,
}))) {
Ok(Denied(reason)) => {
warn!(
"Denied REASON={} {}",
DisplayReason(&reason),
call_summary()
);
Err(reason)?
}
Ok(Deferred) => client::send(request).await,
Err(code) => {
error!(
"Latch error CODE={code} {summary}",
code = DisplayLatchError(&code),
summary = call_summary()
);
Err(code)?
}
}
}
}

impl From<ErrorCode> for HttpErrorCode {
fn from(value: ErrorCode) -> Self {
match value {
ErrorCode::InvalidConfig(latch) => {
Self::InternalError(Some(format!("latch-error: invalid-config<{latch}>")))
}
ErrorCode::ObservationFailed(latch) => {
Self::InternalError(Some(format!("latch-error: observation-failed<{latch}>")))
}
ErrorCode::Other(Some(message)) => {
Self::InternalError(Some(format!("latch-error: {message}")))
}
ErrorCode::Other(None) => Self::InternalError(Some("latch-error".to_string())),
}
}
}

impl Display for types::Method {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&http_utils::format_http_method!(types::Method, self))
}
}

struct DisplayLatchError<'a>(&'a ErrorCode);
impl fmt::Display for DisplayLatchError<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self(ErrorCode::InvalidConfig(latch)) => {
write!(f, "invalid-config<{latch}>")
}
Self(ErrorCode::ObservationFailed(latch)) => {
write!(f, "observation-failed<{latch}>")
}
Self(ErrorCode::Other(Some(message))) => f.write_str(message),
Self(ErrorCode::Other(None)) => f.write_str("other"),
}
}
}

/// Displays a denial reason, the name of the error code, e.g. `http-request-denied`.
///
/// The generated bindings already implement `Display` for [`HttpErrorCode`], with its debug form.
struct DisplayReason<'a>(&'a HttpErrorCode);
impl fmt::Display for DisplayReason<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&http_utils::format_http_error_code!(HttpErrorCode, self.0))
}
}

struct DisplayOption<T>(Option<T>);
impl<T: fmt::Display> fmt::Display for DisplayOption<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.0 {
Some(value) => write!(f, "some<{}>", value),
None => write!(f, "none"), // Customize what to print if empty
}
}
}

wit_bindgen::generate!({
path: "../wit",
world: "gate-client",
merge_structurally_equal_types: true,
generate_all
});

export!(GatedHttpClient);
Loading
Loading