Skip to content

Commit 342ed99

Browse files
hyperpolymathclaude
andcommitted
feat: SafeTrust integration, dedicated regex ETS table, rate limiter
Wire proven/SafeTrust.idr verified trust hierarchy into the gateway, replacing the ad-hoc evaluate_access/2 function. All access decisions now flow through SafeTrust.evaluate/2 with structured audit trails. Trust levels are atoms from a fixed set (no String.to_atom on input). Split policy ETS storage into dual tables: main table for O(1) exact path and global rule lookups, dedicated regex table for Tier 2 scans. Atomic reload swaps both tables as a pair for zero-downtime reloads. Add token bucket rate limiter as a Plug with per-trust-level quotas (untrusted=10/s, authenticated=100/s, internal=unlimited). Uses ETS for O(1) bucket state. Returns 429 + Retry-After on rate limit hit. Client key derived from X-Forwarded-For first entry or peer IP. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f3d0549 commit 342ed99

5 files changed

Lines changed: 799 additions & 156 deletions

File tree

.machine_readable/STATE.scm

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,23 @@
1919
(OTP "27+")
2020
(Plug "HTTP interface")
2121
(Cowboy "HTTP server")
22-
(ETS "Policy storage")
22+
(ETS "Policy storage + rate limiter buckets")
2323
(Prometheus "Metrics export")))
2424

2525
(current-position
2626
(phase "production-ready")
27-
(overall-completion 97)
27+
(overall-completion 98)
2828
(components
29-
(policy-pipeline "100% - DSL v1 loader, validator, compiler, tiered lookup")
30-
(http-gateway "100% - Verb enforcement, proxy, stealth, security headers")
31-
(health-checks "100% - /health, /ready endpoints")
29+
(policy-pipeline "100% - DSL v1 loader, validator, compiler, tiered lookup with dedicated regex ETS table")
30+
(http-gateway "100% - Verb enforcement, proxy, stealth, security headers, SafeTrust integration")
31+
(health-checks "100% - /health, /ready endpoints with dual-table and rate limiter stats")
3232
(metrics "100% - Prometheus /metrics endpoint")
3333
(mtls "100% - Certificate-based trust extraction")
3434
(containerization "100% - Containerfile, docker-compose")
35-
(performance "100% - Tiered ETS lookup (exact→regex→global), O(1) literal paths")
36-
(security-hardening "100% - OWASP headers, safe verb allowlist, trust header spoofing protection, atomic policy reload, specific rescue clauses")
35+
(performance "100% - Tiered ETS lookup (exact->regex->global), dedicated regex table, O(1) literal paths")
36+
(security-hardening "100% - OWASP headers, safe verb allowlist, trust header spoofing protection, atomic dual-table reload, specific rescue clauses")
37+
(safe-trust "100% - Verified trust hierarchy from proven/SafeTrust.idr, parse_trust/parse_exposure, evaluate/2")
38+
(rate-limiter "100% - Token bucket per trust level, ETS-backed, 429+Retry-After, X-Forwarded-For client key")
3739
(documentation "80% - ExDoc, README, TOPOLOGY, missing deployment guide"))
3840
(working-features
3941
"Policy loading and validation"
@@ -44,10 +46,13 @@
4446
"Prometheus metrics export"
4547
"mTLS trust level extraction"
4648
"Container deployment"
47-
"Tiered O(1)/O(r)/O(1) policy lookup"
49+
"Tiered O(1)/O(r)/O(1) policy lookup with dedicated regex ETS table"
4850
"Security headers (OWASP hardened)"
4951
"Trust header spoofing protection (strip_untrusted_headers plug)"
50-
"Atomic policy reload (zero-downtime ETS swap)"))
52+
"Atomic dual-table policy reload (zero-downtime ETS swap)"
53+
"SafeTrust verified trust hierarchy (replaces ad-hoc evaluate_access)"
54+
"Token bucket rate limiting per trust level (10/100/unlimited rps)"
55+
"Rate limiter wired into plug pipeline after trust extraction"))
5156

5257
(route-to-mvp
5358
(milestones
@@ -61,8 +66,8 @@
6166
(completed "2026-02-07"))
6267
(v1.0.0
6368
(status "in-progress")
64-
(description "Production ready - health checks, metrics, mTLS, containers")
65-
(progress 95)
69+
(description "Production ready - health checks, metrics, mTLS, containers, rate limiting")
70+
(progress 98)
6671
(remaining
6772
"Deployment guide documentation"
6873
"Policy DSL reference documentation"))))
@@ -75,7 +80,8 @@
7580
"Property tests need DSL v1 format updates")
7681
(low
7782
"Example policy file uses old format (needs DSL v1 update)"
78-
"Benchmark tiered lookup vs flat scan for different policy sizes"))
83+
"Benchmark tiered lookup vs flat scan for different policy sizes"
84+
"Rate limiter bucket cleanup for stale entries (low-priority, minimal memory)"))
7985

8086
(critical-next-actions
8187
(immediate
@@ -86,10 +92,29 @@
8692
"Write policy DSL reference (docs/POLICY-DSL.md)"
8793
"Update performance tests for DSL v1")
8894
(this-month
89-
"Add rate limiting support"
90-
"Add request/response logging"))
95+
"Add request/response logging"
96+
"Add rate limiter bucket cleanup (periodic sweep of stale entries)"))
9197

9298
(session-history
99+
(session
100+
(date "2026-02-28")
101+
(focus "SafeTrust integration, dedicated regex ETS table, rate limiter")
102+
(accomplishments
103+
"Feature 1: Wired SafeTrust.evaluate/2 into gateway.ex replacing ad-hoc evaluate_access/2"
104+
"Feature 1: Trust levels now atoms via SafeTrust.parse_trust/1 (fail-safe to :untrusted)"
105+
"Feature 1: Exposure levels parsed via SafeTrust.parse_exposure/1 (fail-open to :public)"
106+
"Feature 1: extract_trust plug in pipeline stores trust level in conn.assigns"
107+
"Feature 2: Dedicated regex ETS table (policy_regex_table) for Tier 2 lookups"
108+
"Feature 2: Regex routes stored in separate table, no filtering needed during scans"
109+
"Feature 2: Atomic dual-table reload: both main and regex tables swapped as a pair"
110+
"Feature 2: Updated stats/1 to count from both main and regex tables"
111+
"Feature 3: Created RateLimiter plug with token bucket algorithm (ETS-backed)"
112+
"Feature 3: Per-trust-level quotas: untrusted=10/s, authenticated=100/s, internal=unlimited"
113+
"Feature 3: Client key from X-Forwarded-For first entry or peer IP"
114+
"Feature 3: 429 Too Many Requests with Retry-After header on rate limit exceeded"
115+
"Feature 3: Wired into gateway.ex plug pipeline after trust extraction, before routing"
116+
"Updated readiness check to report dual-table stats and rate limiter bucket count")
117+
(notes "Three major features shipped: verified trust hierarchy, optimized regex lookup, and rate limiting. All access decisions now flow through the formally verified SafeTrust module."))
93118
(session
94119
(date "2026-02-28")
95120
(focus "Critical security hardening - 5 fixes")
@@ -104,7 +129,7 @@
104129
(date "2026-02-28")
105130
(focus "Performance + security hardening")
106131
(accomplishments
107-
"Tiered ETS lookup: O(1) exact path O(r) regex routes O(1) global rules"
132+
"Tiered ETS lookup: O(1) exact path -> O(r) regex routes -> O(1) global rules"
108133
"Literal path detection: routes without regex metacharacters stored with {:exact, path, verb} key"
109134
"Security headers plug: X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Cache-Control, Connection"
110135
"Updated stats/1 to report exact_routes vs regex_routes separately"

lib/http_capability_gateway/gateway.ex

Lines changed: 71 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ defmodule HttpCapabilityGateway.Gateway do
3939

4040
alias HttpCapabilityGateway.PolicyCompiler
4141
alias HttpCapabilityGateway.Proxy
42+
alias HttpCapabilityGateway.RateLimiter
43+
alias HttpCapabilityGateway.SafeTrust
4244

4345
# Safe HTTP verb conversion with allowlist.
4446
#
@@ -68,6 +70,8 @@ defmodule HttpCapabilityGateway.Gateway do
6870
plug(Plug.Logger)
6971
plug(:security_headers)
7072
plug(:strip_untrusted_headers)
73+
plug(:extract_trust)
74+
plug(RateLimiter)
7175
plug(:match)
7276
plug(:dispatch)
7377

@@ -160,6 +164,22 @@ defmodule HttpCapabilityGateway.Gateway do
160164
end
161165
end
162166

167+
# Extract trust level from headers/mTLS and store in conn.assigns.
168+
#
169+
# This plug runs BEFORE the RateLimiter plug in the pipeline so that
170+
# rate limiting decisions can be based on the authenticated trust level.
171+
# The trust level is parsed through SafeTrust.parse_trust/1 which
172+
# safely maps strings to atoms from a fixed set (no String.to_atom).
173+
#
174+
# The trust level is stored in conn.assigns[:trust_level] and reused
175+
# by both the rate limiter and the request handler, avoiding duplicate
176+
# extraction work.
177+
defp extract_trust(conn, _opts) do
178+
trust_level_str = extract_trust_level(conn)
179+
trust_level = SafeTrust.parse_trust(trust_level_str)
180+
Plug.Conn.assign(conn, :trust_level, trust_level)
181+
end
182+
163183
# Health check endpoint - doesn't require policy
164184
get "/health" do
165185
handle_health_check(conn)
@@ -265,7 +285,12 @@ defmodule HttpCapabilityGateway.Gateway do
265285

266286
verb ->
267287
# Valid HTTP method -- proceed with policy evaluation.
268-
trust_level = extract_trust_level(conn)
288+
#
289+
# Trust level was already extracted and parsed by the :extract_trust
290+
# plug earlier in the pipeline (stored in conn.assigns[:trust_level]).
291+
# This avoids duplicate header parsing and ensures the rate limiter
292+
# and request handler see the same trust level.
293+
trust_level = Map.get(conn.assigns, :trust_level, :untrusted)
269294

270295
Logger.info("Processing request",
271296
path: path,
@@ -291,20 +316,26 @@ defmodule HttpCapabilityGateway.Gateway do
291316
else
292317
# Lookup policy rule using tiered strategy:
293318
# Tier 1: O(1) exact literal path match
294-
# Tier 2: O(r) regex route pattern scan
319+
# Tier 2: O(r) regex route pattern scan (dedicated regex table)
295320
# Tier 3: O(1) global verb rule fallback
296321
case PolicyCompiler.lookup(policy_table, path, verb) do
297322
{:ok, rule} ->
298-
# Evaluate access decision based on trust level vs exposure requirement
299-
case evaluate_access(trust_level, rule.exposure) do
300-
:allow ->
323+
# Evaluate access decision using SafeTrust.evaluate/2.
324+
# This replaces the ad-hoc evaluate_access/2 function with the
325+
# formally verified trust hierarchy from proven/SafeTrust.idr.
326+
# SafeTrust.evaluate/2 returns {:allow, t, e} or {:deny, t, e}
327+
# providing a structured audit trail for every decision.
328+
exposure = SafeTrust.parse_exposure(rule.exposure)
329+
330+
case SafeTrust.evaluate(trust_level, exposure) do
331+
{:allow, _t, _e} ->
301332
# Forward to backend -- trust level satisfies exposure requirement
302333
duration_us = System.monotonic_time() - start_time
303334
log_decision(request_id, path, verb, trust_level, :allow, rule, duration_us)
304335

305336
Proxy.forward(conn, rule)
306337

307-
:deny ->
338+
{:deny, _t, _e} ->
308339
# Access denied -- apply stealth profile if configured, otherwise 403
309340
duration_us = System.monotonic_time() - start_time
310341
log_decision(request_id, path, verb, trust_level, :deny, rule, duration_us)
@@ -506,43 +537,38 @@ defmodule HttpCapabilityGateway.Gateway do
506537
end
507538
end
508539

509-
# Evaluate if trust level satisfies exposure requirement
510-
@spec evaluate_access(trust_level :: String.t(), exposure :: String.t()) :: :allow | :deny
511-
defp evaluate_access(trust_level, exposure) do
512-
case {trust_level, exposure} do
513-
# Public endpoints - anyone can access
514-
{_, "public"} -> :allow
515-
516-
# Authenticated endpoints - authenticated or internal only
517-
{"authenticated", "authenticated"} -> :allow
518-
{"internal", "authenticated"} -> :allow
519-
520-
# Internal endpoints - internal only
521-
{"internal", "internal"} -> :allow
540+
# NOTE: The previous ad-hoc evaluate_access/2 function has been removed.
541+
# All access decisions now go through SafeTrust.evaluate/2 which implements
542+
# the formally verified trust hierarchy from proven/SafeTrust.idr.
543+
# The access decision is: rank(trust) >= rank(exposure), where ranks are
544+
# untrusted=0, authenticated=1, internal=2 for trust, and
545+
# public=0, authenticated=1, internal=2 for exposure.
546+
# See HttpCapabilityGateway.SafeTrust for the single source of truth.
522547

523-
# All other combinations - deny
524-
_ -> :deny
525-
end
526-
end
527-
528-
# Handle denied requests - apply stealth profile if configured
548+
# Handle denied requests - apply stealth profile if configured.
549+
#
550+
# trust_level is now a SafeTrust atom (:untrusted, :authenticated, :internal).
551+
# We convert to string for stealth profile map lookups and JSON responses,
552+
# since stealth profiles use string keys matching the DSL v1 format.
529553
defp handle_denial(conn, rule, trust_level) do
530554
stealth_profile = get_stealth_profile(rule.stealth_profile)
555+
trust_str = Atom.to_string(trust_level)
531556

532557
{status_code, response_body} =
533558
case stealth_profile do
534559
nil ->
535-
# No stealth - return clear error
560+
# No stealth - return clear error with trust/exposure atoms as strings
536561
{403, %{
537562
error: "Forbidden",
538563
message: "Insufficient trust level for this operation",
539564
required: rule.exposure,
540-
provided: trust_level
565+
provided: trust_str
541566
}}
542567

543568
profile when is_map(profile) ->
544-
# Apply stealth - return configured status for trust level
545-
code = get_stealth_code(profile, trust_level, rule.exposure)
569+
# Apply stealth - return configured status for trust level.
570+
# Stealth profile keys are strings matching DSL v1 format.
571+
code = get_stealth_code(profile, trust_str, rule.exposure)
546572
message = get_stealth_message(code)
547573
{code, %{error: message}}
548574
end
@@ -689,13 +715,26 @@ defmodule HttpCapabilityGateway.Gateway do
689715
|> send_resp(503, Jason.encode!(response))
690716

691717
true ->
692-
# Ready to serve traffic
693-
rule_count = :ets.info(policy_table, :size)
718+
# Ready to serve traffic.
719+
# Report rule counts from both main and regex tables.
720+
regex_table = Application.get_env(:http_capability_gateway, :policy_regex_table)
721+
722+
main_count = :ets.info(policy_table, :size)
723+
724+
regex_count =
725+
if regex_table && :ets.whereis(regex_table) != :undefined,
726+
do: :ets.info(regex_table, :size),
727+
else: 0
728+
729+
rate_limiter_buckets = RateLimiter.bucket_count()
694730

695731
response = %{
696732
status: "ready",
697733
service: "http-capability-gateway",
698-
policy_rules: rule_count
734+
policy_rules: main_count + regex_count,
735+
main_table_rules: main_count,
736+
regex_table_rules: regex_count,
737+
rate_limiter_buckets: rate_limiter_buckets
699738
}
700739

701740
conn

0 commit comments

Comments
 (0)