Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
a54d856
feat(shield): GCRA rate limiting with pluggable keys and limits
polaz Jul 16, 2026
bedba5d
test(shield): add regression test for GCRA idle-key remaining cap
polaz Jul 17, 2026
aa34633
fix(shield): cap GCRA admissible count at bucket capacity
polaz Jul 17, 2026
1862064
fix(shield): correct cross-instance counter accounting
polaz Jul 17, 2026
ebf6e81
fix(shield): de-identify store keys and namespace by rule fingerprint
polaz Jul 17, 2026
9a2bc82
test(shield): add regression test for sub-second Retry-After rounding
polaz Jul 17, 2026
e022c8f
fix(shield): round Retry-After / RateLimit-Reset up from nanoseconds
polaz Jul 17, 2026
e1037f0
fix(shield): reject non-positive profile rate and burst
polaz Jul 17, 2026
67895d5
fix(proxy): apply CORS outside the auth and rate-limit layers
polaz Jul 17, 2026
93ed030
fix(shield): bound the limit-service resolution cache
polaz Jul 17, 2026
51e3c6e
fix(shield): store GCRA theoretical arrival times in nanoseconds
polaz Jul 17, 2026
88c3bc3
docs(shield): clarify phase-local fallback, header contract, and over…
polaz Jul 17, 2026
7bb7c84
docs(shield): explain TAT reuse across a resolved-tier change
polaz Jul 17, 2026
b8ae323
test(shield): add regression test for evicting an active stale limit …
polaz Jul 17, 2026
850dea7
fix(shield): evict limit cache by last access, not last fetch
polaz Jul 17, 2026
4427cea
docs(shield): clarify JWT-limit scope and the fleet gate approximation
polaz Jul 17, 2026
bf3cc35
fix(shield): make delta push atomic and guard stale estimate writes
polaz Jul 17, 2026
80bf2d5
fix(shield): preserve inner limiter's rate-limit headers
polaz Jul 17, 2026
111d52e
fix(shield): carry unpushed deltas across an epoch roll
polaz Jul 17, 2026
0db9e92
fix(shield): race-free reconcile via claim model, refresh, and eviction
polaz Jul 17, 2026
2278c1c
fix(shield): fail closed on empty rules and report fleet remaining
polaz Jul 17, 2026
4ddb413
fix(shield): reject dynamic zero rates and bound fetch storms
polaz Jul 17, 2026
065a414
fix(shield): drop unpushable deltas instead of restoring (never double)
polaz Jul 17, 2026
04a3ed1
style(shield): match phase on borrowed key
polaz Jul 17, 2026
3b0280e
fix(shield): validate limit-service endpoint and honor unknown-tier n…
polaz Jul 17, 2026
7412910
test(shield): add regression test for carryover epoch collapse
polaz Jul 17, 2026
bc68273
fix(shield): degrade on stale estimate, gate on carryover, no epoch c…
polaz Jul 17, 2026
f0e5c30
fix(shield): reject invalid header key names at startup
polaz Jul 17, 2026
dd46db9
fix(shield): throttle limit-service retries for stale keys during an …
polaz Jul 17, 2026
02aad01
fix(shield): report tightest phase budget and a decaying global Retry…
polaz Jul 17, 2026
b519fd4
fix(shield): auto-reconnect the shared store; note window-reset is no…
polaz Jul 17, 2026
0697fcb
fix(shield): require an http/https limit-service endpoint
polaz Jul 17, 2026
c9afc81
fix(shield): normalize header key names before fingerprinting
polaz Jul 17, 2026
3488749
fix(shield): bound rate-limit state under key-cardinality floods
polaz Jul 17, 2026
d208a7c
fix(shield): fail closed on unknown client IP and unstarted fleet gate
polaz Jul 17, 2026
d2e7107
test(shield): reject unknown config fields and assert outer budget he…
polaz Jul 17, 2026
4c16ca6
docs(shield): document burst>limit behaviour under fleet reconciliation
polaz Jul 17, 2026
c12f872
test(shield): add regression test for reset tie-break on equal remaining
polaz Jul 17, 2026
c8ae720
fix(shield): break RateLimit-Reset ties toward the longer-binding phase
polaz Jul 17, 2026
a67797e
fix(shield): throttle high-water GCRA sweeps to bound CPU
polaz Jul 17, 2026
b5bef30
fix(shield): enforce limit-service cache cap at insertion
polaz Jul 17, 2026
041a87d
refactor(shield): extract reconciled_headers helper
polaz Jul 21, 2026
7aefc04
test(shield): add regression test for fleet-bound RateLimit-Reset
polaz Jul 21, 2026
f6da6a5
fix(shield): report fleet-derived reset when the fleet budget binds
polaz Jul 21, 2026
021352f
docs(shield): note reconciliation needs the redis feature and a sync …
polaz Jul 21, 2026
70d067f
build(deps): bump EmbarkStudios/cargo-deny-action from 2.0.20 to 2.1.1
polaz Jul 21, 2026
13acd1a
test(shield): add regression test for Retry-After on tie-break overwrite
polaz Jul 22, 2026
068517b
fix(shield): widen Retry-After when the outer budget wins a header tie
polaz Jul 22, 2026
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
16 changes: 15 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ jobs:
quality-checks:
name: Quality Checks (${{ matrix.backend.name }})
runs-on: ubuntu-latest
services:
# Shared store for the rate-limit cross-instance reconciliation test.
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -53,6 +64,9 @@ jobs:
run: cargo build --release ${{ matrix.backend.flags }}

- name: Test
env:
# Exercises the rate-limit reconciliation against the Redis service.
SHIELD_REDIS_TEST_URL: redis://127.0.0.1:6379/
run: cargo test ${{ matrix.backend.flags }}

- name: Publish dry-run
Expand All @@ -69,6 +83,6 @@ jobs:
# Scans Cargo.lock for RustSec advisories; honors the ignore list in
# deny.toml. Third-party action: pinned to a commit SHA (dependabot
# keeps it fresh).
- uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2.0.20
- uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1
with:
command: check advisories
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ dashmap = "6"
async-trait = "0.1"
ipnet = "2"
# Optional shared rate-limit store for multi-instance deployments.
redis = { version = "1.2", features = ["tokio-comp"], optional = true }
redis = { version = "1.2", features = ["tokio-comp", "connection-manager"], optional = true }

# Auth (JWT validation, route policies).
# Crypto backend is selected via this crate's `rust_crypto` / `aws_lc_rs`
Expand Down
108 changes: 94 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Works with **any** gRPC service via proto descriptor files. No code generation,
- **Health endpoints** `/health/live`, `/health/ready` (upstream gRPC health probe), `/health/startup`
- **Prometheus metrics** at `/metrics`
- **CORS** with a configurable origin allow-list
- **Rate limiting (Shield)**: per-client endpoint classes + per-identifier limits, in-process by default or Redis-backed (feature `redis`) for multi-instance
- **Rate limiting (Shield)**: local GCRA shaper (no blocking latency) keyed by client IP, header, or validated JWT claim; named limit tiers as config data; optional async cross-instance reconciliation for an approximate fleet-wide limit (requires both the `redis` feature and a configured `sync` block)
- **JWT auth**: validate `Bearer` tokens via an Ed25519 PEM key or JWKS auto-discovery, enforce per-route `require_auth` / `required_roles`, and forward claims as headers
- **OIDC discovery**: serve `/.well-known/openid-configuration` and a JWKS endpoint (Ed25519) built from config, to front an identity provider
- **Forward-auth**: a verification endpoint (`/auth/verify`) for a fronting proxy (nginx `auth_request`, Traefik `forwardAuth`) to delegate auth, returning the verified identity as headers
Expand Down Expand Up @@ -108,25 +108,38 @@ streaming:
sse_keep_alive_secs: 15

# Rate limiting (Shield)
#
# Every decision is made locally with a GCRA shaper (no blocking latency).
# Named profiles define tiers as data; rules bind a path glob to a key and a
# limit. Rules keyed by a JWT claim run after auth (so the claim is verified);
# the rest run before auth so anonymous floods are shed cheaply.
shield:
enabled: true
window_secs: 60 # default window for bare counts like "20"
# Optional: shared counters across replicas (needs the `redis` build feature).
# Omit for an in-process per-replica store.
# redis_url: "redis://127.0.0.1/"
# CIDR ranges of trusted proxies/LBs. X-Forwarded-For is honored only from
# these peers; set this behind a load balancer for correct per-client limits.
trusted_proxies: ["10.0.0.0/8"]
# Classify endpoints by glob pattern → class → rate (limited per client IP)
endpoint_classes:
# Limit tiers: sustained rate ("N/unit" or a bare count = per minute) + burst
# (max back-to-back requests; defaults to one window of the rate).
profiles:
anon: { rate: "60/min", burst: 20 }
premium: { rate: "1000/min", burst: 100 }
# Applied when a matched rule resolves no other limit.
default_profile: "anon"
rules:
# Anonymous heavy endpoints, keyed by client IP (runs before auth).
- pattern: "/api/v1/heavy-*"
class: "heavy"
rate: "10/min"
# Per-identifier limits keyed by a request body field
identifier_endpoints:
- path: "/api/v1/login"
body_field: "email"
rate: "5/min"
key: { type: ip }
profile: "anon"
# Per-principal limit keyed by a validated JWT claim (runs after auth).
- pattern: "/api/v1/**"
key: { type: jwt_claim, claim: "sub" }
# Optional: resolve a key's limit from the JWT itself (tier name → a profile,
# or explicit ratelimit_rpm / ratelimit_burst claims).
# jwt_limits: { tier_claim: "ratelimit_tier" }
# Optional: async cross-instance reconciliation via a shared store for an
# approximate fleet-wide limit (needs the `redis` build feature). The request
# path never blocks on it; a store outage degrades to per-instance limits.
# sync: { redis_url: "redis://127.0.0.1/", interval_ms: 500 }

# JWT auth
auth:
Expand Down Expand Up @@ -165,6 +178,73 @@ buf build -o my-service.descriptor.bin
protoc --descriptor_set_out=my-service.descriptor.bin --include_imports *.proto
```

## Rate limiting

Shield is an embedded, config-driven limiter designed for a data plane: every
decision is made in-process by a GCRA shaper, so it adds no blocking latency to
the request path. GCRA (a token-bucket equivalent storing one timestamp per key)
lets legitimate bursts through up to a configured `burst` while throttling
sustained abuse to the `rate`, with no fixed-window boundary burst.

**Keying and phases.** A rule keys on the client IP, a header value (API key),
or a validated JWT claim. The phase is derived from the key, not configured: an
IP/header rule needs no verified identity so it runs *before* auth (a fast,
purely local check that sheds anonymous floods before any signature verification,
and short-circuits so blocked clients never reach the auth layer); a `jwt_claim`
rule needs the verified principal so it runs *after* auth. A key falls back to
the client IP when its value is absent within its own phase, so a limit can't be
dodged by omitting a header. Note the fallback is phase-local: an anonymous
request under a `jwt_claim` rule keys by IP in the post-auth phase, but is *not*
shed pre-auth. For anonymous flood protection, add a separate pre-auth IP (or
header) rule covering the same paths; a path may match one rule per phase and
each is enforced independently (defense in depth).

**Limit sources.** A key's `{rate, burst}` resolves in order: the JWT itself
(a `ratelimit_tier` claim naming a profile, or explicit `ratelimit_rpm` /
`ratelimit_burst`), then an external service (cached and refreshed in the
background, never blocking), then the rule's pinned profile, then the default.
JWT-based resolution only applies to `jwt_claim` rules, since only they run with
verified claims available; setting `jwt_limits` has no effect on an IP/header
rule, which runs pre-auth (use a `jwt_claim` key if you want the token's tier to
drive the limit). Tier-name indirection lets you retune the numbers in config
without re-issuing tokens or changing the service.

**Response headers.** Every metered response carries the
[draft-ietf-httpapi-ratelimit-headers](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/)
fields: `RateLimit-Limit` (the tier's per-window quota), `RateLimit-Remaining`
(requests still admissible now), and `RateLimit-Reset` (whole seconds until the
limiter drains toward full). A rejected request returns `429` with `Retry-After`
(whole seconds until a retry would conform). Clients should back off for
`Retry-After` seconds on a `429`, and may pace themselves using `RateLimit-*` on
allowed responses. Behind a browser, these are exposed via CORS.

**Deployment modes.**

- **Local (default).** No shared store. Each instance enforces the limit
independently, so the fleet-wide effect is roughly `N × rate` for `N`
instances. Zero dependencies, lowest latency. Set per-instance limits with
that multiplier in mind.
- **Reconciled (`sync` + `redis` feature).** Instances asynchronously push their
deltas to a shared store and pull the aggregate on an interval, converging on
an approximate fleet-wide limit. The configured `rate` is then the *fleet*
budget. The request path still never blocks on the store; if the store is
unreachable, instances degrade to local limiting rather than failing requests.

**Sizing the overshoot.** In reconciled mode the aggregate lags by up to one
`sync.interval_ms`. Within that lag each of the other instances can admit its
local `burst` plus a `rate` fraction of the window before the estimate catches
up (the fleet gate caps sustained fleet volume at `rate`, but `burst` is a
per-instance allowance the gate does not pre-reserve). With the interval in the
same time unit as the window, the worst-case fleet overshoot is about
`(N - 1) × (burst + rate × (interval / window))` requests. For example,
`burst = 100`, `rate = 1000/min`, `interval = 500 ms`, `N = 4` gives
`3 × (100 + 1000 × (0.5 / 60)) ≈ 325` extra requests. Smaller `burst` and shorter
intervals tighten the bound. The global view uses a sliding-window counter, so
there is no boundary burst on top of this lag.

See the `shield:` block under [Configuration](#configuration) for the full
schema.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
## Library Usage

```rust
Expand Down
4 changes: 3 additions & 1 deletion src/auth/forward.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ impl ForwardAuth {
match self.auth.decide(headers, &path, &method).await {
// 200 carries the verified claim headers for the fronting proxy to
// copy upstream.
AuthDecision::Allow(claim_headers) => (StatusCode::OK, claim_headers).into_response(),
AuthDecision::Allow(claim_headers, _) => {
(StatusCode::OK, claim_headers).into_response()
}
AuthDecision::Unauthenticated(msg) => self.deny(msg),
AuthDecision::Forbidden(msg) => forbidden(msg),
}
Expand Down
2 changes: 1 addition & 1 deletion src/auth/jwks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const JWKS_HTTP_TIMEOUT: Duration = Duration::from_secs(5);
/// Uses the pure-Rust `ring` provider (installed per-config, not process-global)
/// and bundles Mozilla's root store via `webpki-roots`, so the binary needs no
/// system CA bundle and works in musl / scratch / distroless images.
fn build_tls_config() -> rustls::ClientConfig {
pub(crate) fn build_tls_config() -> rustls::ClientConfig {
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
Expand Down
24 changes: 20 additions & 4 deletions src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,14 +120,23 @@ impl Auth {

/// The outcome of an auth check for a request.
pub(crate) enum AuthDecision {
/// Allowed; forward these (verified) claim headers to the upstream.
Allow(HeaderMap),
/// Allowed; forward these (verified) claim headers to the upstream, and the
/// verified claims themselves (`None` for anonymous access) for downstream
/// consumers such as per-principal rate limiting.
Allow(HeaderMap, Option<Value>),
/// Rejected: no/invalid credentials (HTTP 401).
Unauthenticated(&'static str),
/// Rejected: authenticated but lacking a required role (HTTP 403).
Forbidden(&'static str),
}

/// Verified JWT claims attached to the request by the auth middleware. Present
/// only when a valid token was supplied, and set exclusively from a verified
/// token (never from client input), so downstream consumers may safely key
/// security decisions (e.g. rate limits) on it.
#[derive(Clone)]
pub(crate) struct ValidatedClaims(pub(crate) std::sync::Arc<Value>);

impl Auth {
/// Evaluate auth for a request: validate the bearer token, apply the route
/// policy, and render the claim headers to forward. This is the single
Expand Down Expand Up @@ -168,7 +177,7 @@ impl Auth {
if let Some(claims) = &claims {
inject_claim_headers(&mut claim_headers, claims, &self.claims_headers);
}
AuthDecision::Allow(claim_headers)
AuthDecision::Allow(claim_headers, claims)
}
}

Expand All @@ -189,11 +198,18 @@ pub async fn middleware(
match auth.decide(request.headers(), &path, &method).await {
AuthDecision::Unauthenticated(msg) => unauthorized(msg),
AuthDecision::Forbidden(msg) => forbidden(msg),
AuthDecision::Allow(claim_headers) => {
AuthDecision::Allow(claim_headers, claims) => {
let dst = request.headers_mut();
for (name, value) in &claim_headers {
dst.insert(name.clone(), value.clone());
}
// Expose the verified claims to inner layers (e.g. per-principal rate
// limiting) as a typed extension a client cannot forge.
if let Some(claims) = claims {
request
.extensions_mut()
.insert(ValidatedClaims(std::sync::Arc::new(claims)));
}
next.run(request).await
}
}
Expand Down
Loading