Chio/Docs

BuildHTTP Frameworks

JVM and .NET HTTP Frameworks

Protect Spring Boot and ASP.NET Core routes through a local Chio sidecar over HTTP.

What it shows

  • world.chio:chio-spring-boot: a ChioFilter registered as a FilterRegistrationBean at highest precedence.
  • Backbay.Chio for ASP.NET Core: AddChioProtection() plus UseChioProtection().
  • Both bind to a local chio api protect sidecar over HTTP. No FFI, no embedded kernel.
  • Request bodies remain readable by the controller after Chio hashing. Receipt ids appear on the response header path for governed routes.

Binding pattern

The JVM and .NET SDKs do the same thing the Node and Python SDKs do: speak HTTP to a sidecar that holds the kernel, the policy, and the receipt store. There is no JNI/PInvoke step. That keeps the runtime footprint small and avoids platform-specific native loading. See HTTP Framework Middleware.

Prerequisites

  • Spring Boot: a JDK 17+ (the example uses Kotlin 2.3 + Spring Boot 3.2). The Gradle wrapper at sdks/jvm/gradlew handles the rest.
  • .NET: the .NET 8 SDK. The example pulls ChioMiddleware as a project reference.
  • The chio CLI on PATH. The smokes start a local chio trust serve and chio api protect.

Run them

bash
cd examples/hello-spring-boot   # or hello-dotnet
./run.sh

# Full smoke (sidecar + trust + deny + allow)
./smoke.sh

Default ports:

ExampleEnv varDefault
hello-spring-bootSpring Boot default config8080
hello-dotnetHELLO_DOTNET_PORT8019

Spring Boot

The integration is one bean. Build a ChioFilter with the sidecar URL, wrap it in a FilterRegistrationBean, and give it Ordered.HIGHEST_PRECEDENCE so it runs before any business filter.

Build file

examples/hello-spring-boot/build.gradle.kts
dependencies {
    implementation(platform("org.springframework.boot:spring-boot-dependencies:3.2.2"))
    implementation("world.chio:chio-spring-boot:0.1.0")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
}

Application

examples/hello-spring-boot/src/main/kotlin/example/hello/HelloSpringBootApplication.kt
package example.hello

import world.chio.ChioFilter
import world.chio.ChioFilterConfig
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.boot.web.servlet.FilterRegistrationBean
import org.springframework.context.annotation.Bean
import org.springframework.core.Ordered
import org.springframework.http.MediaType
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController

@SpringBootApplication
class HelloSpringBootApplication {
    @Bean
    fun chioFilterRegistration(): FilterRegistrationBean<ChioFilter> {
        val filter = ChioFilter(
            ChioFilterConfig(
                sidecarUrl = System.getenv("CHIO_SIDECAR_URL") ?: "http://127.0.0.1:9090",
            ),
        )
        return FilterRegistrationBean<ChioFilter>().apply {
            setFilter(filter)
            addUrlPatterns("/hello", "/echo")
            order = Ordered.HIGHEST_PRECEDENCE
        }
    }
}

@RestController
class HelloController {
    @GetMapping("/healthz")
    fun healthz(): Map<String, String> = mapOf("status" to "ok")

    @GetMapping("/hello")
    fun hello(): Map<String, String> = mapOf("message" to "hello from spring-boot")

    @PostMapping("/echo", consumes = [MediaType.APPLICATION_JSON_VALUE])
    fun echo(@RequestBody payload: EchoRequest): Map<String, Any> = mapOf(
        "message" to payload.message,
        "count" to payload.count,
    )
}

data class EchoRequest(val message: String, val count: Int = 1)

fun main(args: Array<String>) {
    runApplication<HelloSpringBootApplication>(*args)
}

Notes: ChioFilter wraps the servlet request to cache the body bytes for replay; the @RequestBody binding still works on echo after the filter has hashed the bytes. The receipt id is set on the response header path; controllers do not need to thread it through manually. ChioFilterConfig has no skip or exclude list, so the registration governs exactly the two URL patterns it names. Registering on /hello and /echo is what keeps /healthz out of the sidecar path.


ASP.NET Core

Register the service, then gate the middleware so it covers route except the readiness probe. The example uses ASP.NET's minimal-API style.

Project file

examples/hello-dotnet/HelloChio.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="../../sdks/dotnet/ChioMiddleware/src/ChioMiddleware.csproj" />
  </ItemGroup>

</Project>

Program

examples/hello-dotnet/HelloApp.cs
using Backbay.Chio;

namespace HelloDotnet;

internal static class HelloApp
{
    internal static WebApplication Create(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);
        builder.Services.AddChioProtection();

        var app = builder.Build();

        // /healthz is mapped before the guard and left out of it, so the
        // readiness probe never depends on the sidecar.
        app.MapGet("/healthz", () => Results.Json(new { status = "ok" }));

        app.UseWhen(
            context => RequiresChioProtection(context.Request.Path),
            branch => branch.UseChioProtection());

        app.MapGet("/hello", () => Results.Json(new { message = "hello from dotnet" }));

        app.MapPost("/echo", (EchoRequest payload) =>
            Results.Json(new
            {
                message = payload.Message,
                count = payload.Count,
            }));

        return app;
    }

    internal static bool RequiresChioProtection(PathString path) =>
        !path.Equals("/healthz", StringComparison.OrdinalIgnoreCase);
}

internal sealed record EchoRequest(string Message, int Count = 1);

Notes: app.UseWhen(...) runs UseChioProtection() only on paths where RequiresChioProtection returns true, which excludes /healthz. Body bytes are buffered with EnableBuffering()-style semantics so the model binder can re-read them. Configure the sidecar URL via standard ASP.NET configuration (env var, appsettings, or DI options).

bash
./run.sh
# starts dotnet on http://127.0.0.1:8019 (override with HELLO_DOTNET_PORT)

Critical wiring

One bean (Spring) and one middleware call (.NET). These are the lines that flip on Chio enforcement.

examples/hello-spring-boot/.../HelloSpringBootApplication.kt:151
@Bean
fun chioFilterRegistration(): FilterRegistrationBean<ChioFilter> {
    val filter = ChioFilter(ChioFilterConfig(sidecarUrl = ...))
    return FilterRegistrationBean<ChioFilter>().apply {
        setFilter(filter)
        addUrlPatterns("/hello", "/echo")
        order = Ordered.HIGHEST_PRECEDENCE
    }
}
examples/hello-dotnet/HelloApp.cs
builder.Services.AddChioProtection();
var app = builder.Build();
app.UseWhen(
    context => RequiresChioProtection(context.Request.Path),
    branch => branch.UseChioProtection());

Smoke assertions

Both smokes drive the same three-call pattern: GET allow, POST deny without capability, POST allow with capability. The deny body for Spring and .NET both carry error: chio_access_denied and a receipt_id field.

examples/hello-spring-boot/smoke.sh
# /hello
assert body["message"] == "hello from spring-boot", body

# /echo without token (403)
assert body["error"] == "chio_access_denied", body
assert body["receipt_id"], body

# /echo with X-Chio-Capability
assert body["message"] == "hello", body
assert body["count"] == 2, body
examples/hello-dotnet/smoke.sh
# Same shape: chio_access_denied + receipt_id on deny
assert body["message"] == "hello from dotnet", body
assert body["error"] == "chio_access_denied", body
assert body["receipt_id"], body

Inspect after

bash
cd .artifacts/$(ls -t .artifacts | head -1)

# Receipt id arrives as x-chio-receipt-id (lower-case)
grep -i x-chio-receipt-id hello.headers deny.headers allow.headers

# 3 persisted receipts in the sidecar SQLite store
wc -l receipts.ndjson                   # expect: 3
jq -r '.verdict.verdict' receipts.ndjson | sort | uniq -c
# expect: 2 allow, 1 deny

# Confirm the deny receipt id matches the one in deny.json
jq -r '.receipt_id' deny.json
grep -i x-chio-receipt-id deny.headers

# Direct SQLite peek
sqlite3 state/sidecar-receipts.sqlite3 \
  "select id, json_extract(receipt_json, '$.method') as method, json_extract(receipt_json, '$.route_pattern') as route_pattern from http_receipts order by rowid;"

When this fits

Use this when: your Spring Boot or ASP.NET service can take an SDK dependency and you want receipts available on the response header path with body bytes replayed for the controller. Don't use this if the service is closed-source or you cannot redeploy: run chio api protect in front of it instead. See OpenAPI Sidecar.

JVM vs .NET, in one table

AspectSpring BootASP.NET Core
SDK packageworld.chio:chio-spring-bootBackbay.Chio (project reference)
Pipeline shapeServlet filter via FilterRegistrationBeanASP.NET middleware
OrderOrdered.HIGHEST_PRECEDENCEUseWhen placement in HelloApp.cs
Body reuseWrapped servlet requestStream buffering on HttpRequest
Sidecar URLCHIO_SIDECAR_URL envASP.NET config (env, appsettings)

Why HTTP and not FFI

Both runtimes have mature FFI surfaces (JNI, P/Invoke), but the sidecar shape wins on three counts:

  • No native build per platform: the Java archive and the .NET assembly stay pure managed code; the sidecar binary handles platform-specific concerns.
  • Shared state: the sidecar holds policy, kernel state, and receipts. Multiple managed processes can share one sidecar.
  • Crash isolation: a bug in the kernel cannot bring down the JVM or the .NET host.

The cost is one localhost roundtrip per evaluated request, which the sidecar adds one localhost round trip per evaluated request.


Next