Skip to content

Add per-identity rate limit on data proxied (with revocation propagation) - #6

Closed
jude-sph wants to merge 7 commits into
securitylevel5:mainfrom
jude-sph:feat/per-identity-rate-limit
Closed

Add per-identity rate limit on data proxied (with revocation propagation)#6
jude-sph wants to merge 7 commits into
securitylevel5:mainfrom
jude-sph:feat/per-identity-rate-limit

Conversation

@jude-sph

Copy link
Copy Markdown

Add per-identity rate limit on data proxied (with revocation propagation)

What this adds

A per-identity, byte-denominated rate limit on data proxied through the
gateway. Each permission row carries a signed capacity_bytes and
refill_bytes_per_sec; the gateway enforces them both at CONNECT time
(refuse with 429) and continuously during the tunnel (drain → close).

While building the bucket infrastructure I realised the same mechanism
naturally addresses two existing gaps: permission expiry and explicit
revocation today only affect new CONNECTs, not in-flight tunnels.
The bucket now also closes the tunnel when not_after passes (inline,
on every write) or when a background poll learns the permission has
been revoked (within 30 seconds).

Design choices and tradeoffs

  • Limit values are signed, not config-based, so a compromised
    gateway cannot raise them. (See policy.rs::canonical_permission_bytes,
    bumped to v2.)
  • Upload-only metering, aligning with the SL5 paper's egress
    framing. Doubles the design surface for marginal additional
    protection if we counted downloads.
  • Hard cutover to v2 (no v1 fallback). Existing v1 rows fail
    verification; the demo repo's registry-cli will need a small
    parallel update (see note at the end). Simpler than a v1/v2
    conditional verifier.
  • In-memory bucket state. Lost on restart. Acceptable because the
    signed not_after bounds total harm per permission at
    (not_after - not_before) × refill + capacity.
  • Bucket keyed by permission_id. Reissuance is the explicit
    reset mechanism. Bucket parameters are immutable after creation —
    in-place permission updates do not propagate to existing buckets
    until restart, by design.
  • No-overshoot guarantee. MeteredStream reserves tokens before
    the inner write and refunds whatever the inner stream did not
    accept. Tokens consumed = bytes the TCP stack confirmed accepted.
    Verified by a unit test (100 threads racing on a 1 KiB bucket sum to
    exactly 1000 bytes) and an end-to-end test (echo server asserts ≤
    capacity bytes received).
  • No new crates. Hand-rolled token bucket with std::sync::Mutex
    and AtomicBool. Consistent with the existing pattern (X.509
    parsing, canonical bytes, hex encoding all hand-rolled).

Known limitations (also in README)

  • In-memory state lost on restart; bounded by signed not_after.
  • Per-replica, not aggregated across multi-gateway deployments.
  • No bucket GC; memory bounded by distinct permission_ids ever seen.
  • Bucket parameters fixed at creation; admin reissues to change limits.
  • Revocation propagation up to 30 seconds (the poll interval); rate
    limit continues to enforce throughout the window, so total transfer
    between revocation and the next poll is capped at one burst plus 30
    seconds of sustained throughput at the signed rate.

Required parallel change in agent-gateway-demo

The registry-cli/agent-permissions.sh script in
SL5TaskForce/agent-gateway-demo currently signs v1 canonical bytes.
After this PR is merged, two extra lines need to be added to the
<<EOF block in cmd_grant:

capacity_bytes=$CAPACITY_BYTES
refill_bytes_per_sec=$REFILL_BYTES_PER_SEC

with corresponding --set= flags on the psql invocation that inserts
the row, and two additional CLI parameters surfacing the values.
Without this change, the demo's permissions will fail verification
against this gateway.

Verification

  • cargo build --release — green
  • cargo clippy --all-targets --all-features -- -D warnings — green
  • cargo test — all 80 tests pass, including:
    • Unit tests in src/rate_limit.rs for bucket math, refund,
      concurrent consume (no overshoot), retry-after, expiry, and
      revocation (14 tests).
    • Integration test policy_rejects_tampered_capacity_column
      (proves the signed-not-config security property).
    • Integration test policy_with_rate_limit_exhausts_then_denies.
    • Integration test tunnel_closes_when_permission_revoked.
    • E2E test tunnel_closes_on_bucket_drain_no_overshoot (the
      algorithmic no-overshoot proof at the IO-wrapper layer).
    • E2E test subsequent_connect_after_drain_returns_429.

jude-sph added 7 commits May 17, 2026 01:30
…registry

Adds the two byte-budget columns that will be signed in v2 of the
canonical permission bytes. Bumps the schema version to 2 and updates
the verifier to emit and check the new format. Test helpers are
migrated to v2 with effectively-unlimited defaults (i64::MAX) so the
existing test suite keeps passing without per-test rewrites.

PolicyDecision::Allow now carries an AllowedPermission struct bundling
the matched row's permission_id, capacity_bytes, refill_bytes_per_sec,
and not_after. Subsequent commits use these to set up the rate-limit
bucket. Adds a tampering test that proves the v2 signature catches
unsigned changes to capacity_bytes.
Adds a new module exposing TokenBucket, BucketStore, and MeteredStream.
The bucket holds capacity_bytes, refill_bytes_per_sec, and the matched
permission's not_after as immutable u64/DateTime fields. try_consume_up_to
is atomic and short-circuits to zero when the permission has expired,
making the bucket the natural mid-tunnel kill switch.

MeteredStream wraps an AsyncWrite, reserving tokens before each inner
write and refunding any tail the inner stream did not accept. This
makes overshoot impossible: tokens consumed equals bytes the inner TCP
stack confirmed accepted. Reads are delegated straight through; only
the upstream egress direction is metered.

Unit tests cover the bucket math, concurrent consume (no overshoot),
refund semantics on partial writes, retry-after computation, expiry,
the store's get-or-create contract, and the algorithmic no-overshoot
property of MeteredStream against an in-memory duplex pipe.

Mutex locks use the soft-recovery pattern (unwrap_or_else with
PoisonError::into_inner) so a panicking task cannot poison the bucket
state and bring down unrelated tunnels.
ProxyService now consults an in-process BucketStore (keyed by
permission_id) before allowing each CONNECT. If the bucket for the
matched permission is empty, return 429 Too Many Requests with a
Retry-After header computed from the bucket's refill rate. The
bucket is created lazily on first use with the signed capacity,
refill rate, and not_after from the permission row.

MakeProxyService accepts the BucketStore at construction; main.rs
instantiates it alongside the policy engine. The test harness exposes
start_proxy_with_store so integration tests can hold a reference to
the same store the proxy uses, which lets them drain or revoke
buckets without going through the tunnel.

Integration test seeds a v2 row with capacity 100, refill 0,
exhausts the bucket via the test-held store, and asserts the next
CONNECT returns 429.
spawn_tunnel now wraps the upstream TCP socket in a MeteredStream
that reserves bytes from the per-permission bucket before each
inner write and refunds anything the inner stream did not accept.
When the bucket drains mid-stream, the next write returns io::Error
and the tunnel collapses through the existing copy_bidirectional
error path. No new control flow added.

Two end-to-end tests prove the property: the first writes 4 KiB
through a tunnel with a 1 KiB bucket and asserts the destination-side
echo server received at most 1 KiB (the no-overshoot proof), then
inspects the tunnel-error structured log to confirm the rate_limit
error string. The second drains the bucket through the tunnel and
asserts a fresh CONNECT returns 429.
TokenBucket gains a revoked AtomicBool, set once via mark_revoked.
The bucket's is_dead helper now returns true for any of: tokens
exhausted, permission expired, or revoked flag set. All three flow
through the same MeteredStream::poll_write error path, so there is
no special-cased control flow per reason; the bucket is the unified
mid-tunnel kill switch.

A small background task in main.rs polls permission_registry every
30 seconds for rows with revoked_at set and calls mark_revoked on
the matching bucket. The query is read-only and respects the
gateway's read-only database role. The query function is factored
out so integration tests can invoke it directly without waiting on
the timer, which keeps the test deterministic.

Memory ordering on the revoked flag is Release on store / Acquire
on load, which makes the revocation visible to every subsequent
is_dead check across threads.

Adds unit tests for the revoked-bucket behaviour, the
BucketStore::mark_revoked lookup, and idempotency. Adds an
integration test that revokes a permission mid-tunnel and asserts
the next write fails.
…dependency

Updates the README to cover the new 429 status code with the
Retry-After header, the two signed byte-budget fields in the v2
canonical permission format, and the kill-switch behaviour now
shared between cap exhaustion, permission expiry, and explicit
revocation. Adds a section explaining the 30-second revocation
poll and the bounded latency it implies. Adds a note about the
parallel update needed in the sister demo repo's registry-cli.
The previous phrasing said the agent "cannot exfiltrate faster than
capacity_bytes + refill_bytes_per_sec × 30s after a revocation" --
strictly the units don't match (faster is a rate, not a quantity).
Rephrased to make the bound a transferred-bytes quantity and to be
explicit that the 30s is the upper bound on poll-to-mark latency,
not a fixed latency.
@jude-sph jude-sph closed this May 17, 2026
@jude-sph

Copy link
Copy Markdown
Author

Sorry, PR'd here instead of my fork by accident

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant