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
51 changes: 50 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,11 @@ AgentGate is deterministic. Agents never see tokens, calls go only to configured
| `AGENTGATE_ADMIN_SECRET` | Secret for admin API access | Yes |
| `AGENTGATE_PUBLIC_URL` | Base URL used to build the OAuth callback (default `http://localhost:8080`) | No |
| `AGENTGATE_API_KEY_CACHE_TTL` | Verified-key cache duration. Default `30s`; `0` disables caching. | No |
| `AGENTGATE_RATE_KEY_RPS` | Requests per second per API key ID. Default `20`. Zero requires tenant admission disabled. | No |
| `AGENTGATE_RATE_KEY_BURST` | Per-key token bucket burst. Default `40`. Zero requires tenant admission disabled. | No |
| `AGENTGATE_RATE_TENANT_RPS` | Requests per second per tenant. Default `100`; `0` disables this bucket. | No |
| `AGENTGATE_RATE_TENANT_BURST` | Per-tenant token bucket burst. Default `200`; `0` disables this bucket. | No |
| `AGENTGATE_TENANT_MAX_INFLIGHT` | In-flight upstream requests per tenant. Default `32`; `0` disables this cap. | No |
| `AGENTGATE_BISCUIT_MAX_DURATION` | Go duration per Biscuit Datalog world run. Default `250ms`; range `1ms` through `5s`, inclusive. Invalid values stop startup. | No |
| `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | GitHub OAuth credentials | For GitHub OAuth |
| `SLACK_CLIENT_ID` / `SLACK_CLIENT_SECRET` | Slack OAuth credentials | For Slack OAuth |
Expand All @@ -332,6 +337,50 @@ SQLite stores a public ID and a peppered HMAC-SHA256, never the secret. Use `POS
Legacy `ag_live_` rows keep their bcrypt hashes. Successful validation adds an indexed HMAC without changing the presented key.
Revoked rows never enter legacy bcrypt scans.

#### Request admission

API keys have no organization ID. The organization store binds admins, not agent keys.
For explicitly scoped keys, tenant identity is the authorized `on_behalf_of` principal.
Such keys share that principal's rate bucket and concurrency cap.
Each key's bucket spans services and human principals.
Wildcard keys allowing `"*"` use 1 key-scoped tenant for every principal.
Different wildcard keys don't share human quotas.
Each key selects at most 64 tenant identities from its fixed scope list.
When the list exceeds 64 entries, only the first 63 retain shared human identity.
The remaining principals share 1 key-scoped fallback tenant.
Human and fallback identities use separate namespaces. No per-key tenant-tracking map is needed.

Rate settings accept finite, non-negative numbers. Bursts and concurrency caps require integers.
Invalid environment values stop startup. Either zero rate or zero burst disables that bucket.
Both key settings must stay positive while tenant rate limiting or tenant concurrency is enabled.
Per-service limits are optional. Set `rate_limit` under a service in the loaded registry YAML.
They apply per API key and service, alongside the global limits.
See [registry rate limits](docs/registry.md#rate-limits) for YAML examples.

The key bucket runs after API key authentication and body validation.
In-memory service and principal scope checks run next.
Scope denials consume only key tokens. Their receipts retain `scope_denied`.
Tenant and service buckets run after scope checks, before delegation verification or vault access.
Rate denials return HTTP 429 with `rate_limited`.
`Retry-After` rounds the reservation delay up to whole seconds, with a minimum of 1.
Concurrency admission runs immediately before upstream dispatch.
Full tenants return HTTP 429 with `concurrency_limited` and `Retry-After: 1`.
Concurrency rejection refunds the key, tenant, and service charges without losing elapsed refill.
Slots remain held through response completion, including SSE streams and receipt commits.
Failures, disconnects, stream termination, and dispatch panics release them.
Every authenticated, schema-valid attempt still commits a receipt before its response, including admission denials.
Both admission errors record policy `rate_limited`. Receipt failures return `receipt_failed` instead.

Admission state is process-local. Replicas don't share quotas.
The combined bucket and semaphore cache holds at most 100000 entries.
Requests trigger idle sweeps at most once per minute. Entries idle for 10 minutes become eviction candidates.
Only full buckets without pending permits or in-flight requests can be evicted.
Slow buckets retain their quota until fully refilled.
At capacity, admission scans up to 64 entries from the LRU tail.
Pinned or depleted entries move to the front. The first evictable entry is replaced.
If none qualifies, admission fails closed with HTTP 429 and `Retry-After: 1`.
Restart the gateway after changing admission settings or service YAML.

#### Biscuit token security caps

Tokens are limited to 4096 bytes and 8 attenuation blocks.
Expand All @@ -344,7 +393,7 @@ Authority checks follow the same caps.

These checks run before authorizer construction. Rejections match both `delegation.ErrDenied` and `delegation.ErrTokenShape`.
Errors contain fixed reasons, not token contents.
The structural caps bound fact joins and block processing before the request reaches the rate limiter.
The early rate limiter runs before these checks. Structural caps then bound fact joins and block processing.
Biscuit-go v2.2.0 deadlines do not cover check evaluation and cannot reliably stop timed-out goroutines.
Runtime limits alone do not bound per-request CPU.

Expand Down
7 changes: 6 additions & 1 deletion cmd/agentgw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ func main() {
logger.Error("load Biscuit limits", "error", err)
os.Exit(1)
}
rateLimits, err := loadRateLimits()
if err != nil {
logger.Error("load rate limits", "error", err)
os.Exit(1)
}

// Load service registry.
reg := registry.New()
Expand Down Expand Up @@ -141,7 +146,7 @@ func main() {

ledger := receipt.NewLedger(database, signerStore)
defer ledger.Close()
limiter := ratelimit.New(nil) // no per-service limits configured yet; Allow() is a pass-through
limiter := ratelimit.New(reg.RateLimits(), ratelimit.WithSettings(rateLimits))

delegationRoot, err := delegation.NewRootStore(database, masterKey)
if err != nil {
Expand Down
52 changes: 52 additions & 0 deletions cmd/agentgw/rate_limits.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package main

import (
"fmt"
"math"
"os"
"strconv"

"github.com/Clawdlinux/agentgate/internal/ratelimit"
)

func loadRateLimits() (ratelimit.Settings, error) {
settings := ratelimit.DefaultSettings()
for _, field := range []struct {
name string
target *float64
}{
{"AGENTGATE_RATE_KEY_RPS", &settings.KeyRPS},
{"AGENTGATE_RATE_TENANT_RPS", &settings.TenantRPS},
} {
if value, ok := os.LookupEnv(field.name); ok {
parsed, err := strconv.ParseFloat(value, 64)
if err != nil || parsed < 0 || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
return ratelimit.Settings{}, fmt.Errorf("%s must be a finite non-negative number", field.name)
}
*field.target = parsed
}
}
for _, field := range []struct {
name string
target *int
}{
{"AGENTGATE_RATE_KEY_BURST", &settings.KeyBurst},
{"AGENTGATE_RATE_TENANT_BURST", &settings.TenantBurst},
{"AGENTGATE_TENANT_MAX_INFLIGHT", &settings.TenantMaxInflight},
} {
if value, ok := os.LookupEnv(field.name); ok {
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 0 {
return ratelimit.Settings{}, fmt.Errorf("%s must be a non-negative integer", field.name)
}
*field.target = parsed
}
}
tenantEnabled := (settings.TenantRPS > 0 && settings.TenantBurst > 0) || settings.TenantMaxInflight > 0
if tenantEnabled && (settings.KeyRPS == 0 || settings.KeyBurst == 0) {
return ratelimit.Settings{}, fmt.Errorf(
"AGENTGATE_RATE_KEY_RPS and AGENTGATE_RATE_KEY_BURST must be positive while tenant admission is enabled",
)
}
return settings, nil
}
33 changes: 33 additions & 0 deletions cmd/agentgw/rate_limits_review_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package main

import (
"strings"
"testing"
)

func TestTenantAdmissionRequiresKeyLimit(t *testing.T) {
for _, name := range []string{"AGENTGATE_RATE_KEY_RPS", "AGENTGATE_RATE_KEY_BURST"} {
for _, enabled := range []string{"rate", "inflight", "neither"} {
t.Run(name+"/"+enabled, func(t *testing.T) {
clearRateEnv(t)
t.Setenv(name, "0")
t.Setenv("AGENTGATE_RATE_TENANT_RPS", "0")
t.Setenv("AGENTGATE_TENANT_MAX_INFLIGHT", "0")
switch enabled {
case "rate":
t.Setenv("AGENTGATE_RATE_TENANT_RPS", "1")
case "inflight":
t.Setenv("AGENTGATE_TENANT_MAX_INFLIGHT", "1")
}
_, err := loadRateLimits()
if enabled == "neither" {
if err != nil {
t.Fatal(err)
}
} else if err == nil || !strings.Contains(err.Error(), name) {
t.Fatalf("expected key limit validation error, got %v", err)
}
})
}
}
}
75 changes: 75 additions & 0 deletions cmd/agentgw/rate_limits_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package main

import (
"os"
"strings"
"testing"

"github.com/Clawdlinux/agentgate/internal/ratelimit"
)

var rateEnvNames = []string{
"AGENTGATE_RATE_KEY_RPS", "AGENTGATE_RATE_KEY_BURST",
"AGENTGATE_RATE_TENANT_RPS", "AGENTGATE_RATE_TENANT_BURST",
"AGENTGATE_TENANT_MAX_INFLIGHT",
}

func clearRateEnv(t *testing.T) {
t.Helper()
for _, name := range rateEnvNames {
t.Setenv(name, "")
if err := os.Unsetenv(name); err != nil {
t.Fatal(err)
}
}
}

func TestLoadRateLimits(t *testing.T) {
clearRateEnv(t)
settings, err := loadRateLimits()
if err != nil || settings != ratelimit.DefaultSettings() {
t.Fatalf("defaults = %+v, err = %v", settings, err)
}
for _, name := range rateEnvNames {
t.Setenv(name, "0")
}
settings, err = loadRateLimits()
if err != nil || settings != (ratelimit.Settings{}) {
t.Fatalf("disabled = %+v, err = %v", settings, err)
}
t.Setenv("AGENTGATE_RATE_KEY_RPS", "0.5")
t.Setenv("AGENTGATE_RATE_KEY_BURST", "2")
t.Setenv("AGENTGATE_RATE_TENANT_RPS", "12.5")
t.Setenv("AGENTGATE_RATE_TENANT_BURST", "25")
t.Setenv("AGENTGATE_TENANT_MAX_INFLIGHT", "7")
settings, err = loadRateLimits()
want := ratelimit.Settings{KeyRPS: 0.5, KeyBurst: 2, TenantRPS: 12.5, TenantBurst: 25, TenantMaxInflight: 7}
if err != nil || settings != want {
t.Fatalf("overrides = %+v, err = %v", settings, err)
}
}

func TestLoadRateLimitsInvalid(t *testing.T) {
for _, name := range rateEnvNames {
for _, value := range []string{"", "-1", "NaN", "Inf", "1e999", "invalid"} {
t.Run(name+"/"+value, func(t *testing.T) {
clearRateEnv(t)
t.Setenv(name, value)
_, err := loadRateLimits()
if err == nil || !strings.Contains(err.Error(), name) {
t.Fatalf("expected named validation error, got %v", err)
}
})
}
if strings.Contains(name, "RPS") {
continue
}
t.Run(name+"/fraction", func(t *testing.T) {
clearRateEnv(t)
t.Setenv(name, "1.5")
if _, err := loadRateLimits(); err == nil {
t.Fatal("fractional integer accepted")
}
})
}
}
35 changes: 34 additions & 1 deletion docs/registry.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,41 @@
# Registry response settings
# Registry settings

Response limits belong under each service's `upstream` settings.
Streaming permission belongs on each action.

## Rate limits

Optional `rate_limit` settings apply per API key and service.
They combine with the gateway's per-key and per-tenant buckets.
Explicitly scoped keys share human principal tenants. Wildcard keys use key-scoped tenants.
Large scope lists use a key fallback beyond the first 63 principals.
See [request admission](../README.md#request-admission) for identity bounds and startup validation.
The gateway loads them from the same registry YAML as actions.
The separate `configs/agentgate.yaml` example isn't loaded by the gateway.
Restart the gateway after changing these settings.

```yaml
services:
example:
base_url: https://api.example.com
auth: {type: bearer}
rate_limit:
requests_per_second: 25
burst: 50
actions:
list: {method: GET, path: /items}
```

Use at most 1 positive rate field: `requests_per_second`, `requests_per_minute`, or `requests_per_hour`.
Rates must be finite and non-negative. Fractional rates are accepted.
Absent or zero rates disable the service bucket. Omitted or zero `burst` selects 10 tokens.
Negative bursts and conflicting rates fail registry loading.
Rate rejection returns HTTP 429 with `rate_limited` and a whole-second `Retry-After` header.
The gateway commits its receipt before sending that response.
Scope checks run before service and tenant buckets. Concurrency denials refund all token charges.

## Response settings

```yaml
services:
example:
Expand Down
24 changes: 24 additions & 0 deletions internal/gateway/admission.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package gateway

import "github.com/Clawdlinux/agentgate/internal/auth"

// tenantIdentity bounds each key to 64 tenant identities without a separate tracking cache.
// Wildcard keys share one key-scoped tenant across all requested principals.
func tenantIdentity(key *auth.AgentKey, principal string) string {
fallback := "key:" + key.ID
for _, allowed := range key.AllowedUsers {
if allowed == "*" {
return fallback
}
}
limit := 64
if len(key.AllowedUsers) > limit {
limit-- // Reserve one identity for the overflow bucket.
}
for _, allowed := range key.AllowedUsers[:min(limit, len(key.AllowedUsers))] {
if allowed == principal {
return "human:" + principal
}
}
return fallback
}
Loading
Loading