feat(gateway): enforce cert-identity ↔ agent-token tenant binding (Phase 5) - #5
Open
marcorivm wants to merge 2 commits into
Open
feat(gateway): enforce cert-identity ↔ agent-token tenant binding (Phase 5)#5marcorivm wants to merge 2 commits into
marcorivm wants to merge 2 commits into
Conversation
…ase 5)
Turn the mTLS cert identity (Phase 1, logged-only until now) into an
enforced access-control boundary: a relay's client certificate may only
carry agent tokens for its own tenant. Final phase of remote-gateway
hardening.
- New src/binding.rs: pure `evaluate()` + `BindingMode` (off|log|enforce).
PROJECT-scoped equality — a request is permitted iff the ClientHost
resolved from the cert's spiffe://onecli/host/<id> identity has
projectId == the token's project (+ org consistency when the host org
is non-null, + host not revoked). The ClientHost row IS the allowlist
entry: deny-unless-a-matching-non-revoked-row (allowlist-shaped).
- db.rs: find_client_host_by_spiffe — parameterized ($1) lookup on the
unique spiffe_uri column; the identity is untrusted input.
- gateway.rs: a shared `enforce_binding` helper called from BOTH
handle_connect AND handle_http_proxy (after connect::resolve, before
any upstream path — the absolute-form route is not a bypass), 60s
cache incl. negative results.
- SECURITY / correctness:
- `on_mtls` is threaded as a SEPARATE signal from the accept loop
(true for the mTLS listener, false for plain) — NOT inferred from
client_identity. An mTLS cert with an unparseable identity is DENIED
in enforce (can't satisfy the allowlist); the plain listener is
exempt in every mode by listener kind (it's the web-app/loopback
path with no cert).
- Fail-closed: a DB/cache lookup error in enforce DENIES (502,
retryable); permanent verdicts (mismatch / unknown / revoked /
missing identity) return 403. No path turns an error into allow.
- Denial response and audit logs carry no token or secret.
- Mode default is `off` (unset → byte-identical to today; zero
per-request cost). Operators roll out off → log (record would-deny)
→ enforce.
Stacked on Phase 3 (relay). 682 tests, clippy --all-targets -D warnings
+ fmt clean. cargo build --features cloud unchanged (27 pre-existing
ee/*.rs errors, none from this change).
Follow-ups (out of scope): a ClientHost.revokedAt writer (the gateway
already denies revoked hosts within one cache TTL); a live-DB test tier
to cover the sqlx wire-decode of a non-null revoked_at; restricting the
plaintext listener to loopback is required for enforcement to be
meaningful (the gateway warns when it isn't).
Claude-Session: https://claude.ai/code/session_01BgJuqEJqf7ZiUdqHWxi6bt
Operator runbook for hosting the gateway remotely: tag/publish the fork image via the existing publish.yml to ghcr.io/carbonodev/onecli, deploy the remote gateway (raw TCP passthrough for the mTLS port, plain listener on loopback), wire nanoclaw's relay, and roll out binding enforcement off -> log -> enforce. Claude-Session: https://claude.ai/code/session_01BgJuqEJqf7ZiUdqHWxi6bt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 5 (final) of remote-gateway hardening — cert↔token tenant binding enforcement
Stacked on #4 (Phase 3) — base is
phase3/relay-mode. Review/merge #2 → #3 → #4 first; this diff shows only Phase 5.What this PR does
Turns the mTLS certificate identity — extracted and logged since Phase 1 — into an enforced access-control boundary. A relay's client certificate may only carry agent tokens for its own tenant (PROJECT-scoped). This is the payoff of the whole series: the gateway now verifies not just that a caller holds a valid cert, but that the cert's identity is permitted to carry this specific agent's token.
The rule
Permitted iff the
ClientHostresolved from the cert'sspiffe://onecli/host/<id>identity hasprojectId == token.project_id(+ org consistency when the host's org is non-null, + the host is not revoked). TheClientHostrow is the allowlist entry — deny-unless-a-matching-non-revoked-row.Rollout (safe by construction)
GATEWAY_BINDING_ENFORCEMENT = off | log | enforce, defaultoff— unset is byte-identical to today with zero per-request cost. Operators roll out off → log (records every would-deny as a structured audit event without blocking, revealing the real host↔project topology) → enforce.Security / correctness properties
enforce_bindinghelper is called fromhandle_connectANDhandle_http_proxy, each after tenant resolution and before any upstream path — the absolute-form route is not a bypass.on_mtlsis a separate signal, threaded from the accept loop (true for the mTLS listener, false for plain) — NOT inferred from whether the cert identity parsed. So an mTLS cert with an unparseable identity is denied in enforce (can't satisfy the allowlist), while the plain listener (the web-app/loopback path with no cert) is exempt by listener kind in every mode.$1lookup on the uniquespiffe_uricolumn, no interpolation.{"error":"identity_not_permitted"}; audit logs carry spiffe/host-project/token-project/org/agent-id/decision, never the token.ClientHost.revokedAtas an immediate application-layer soft-revoke (within one 60s cache TTL) — an app-layer revoke lever even without CRLs.Testing
682 tests (
cargo test),clippy --all-targets -- -D warnings+fmtclean. Exhaustive unit coverage of the decision table and every deny branch;enforce_bindingoff/exempt/match/mismatch/revoked/unparseable-identity/DB-error paths; the 502-vs-403 split; off-mode zero-lookup (proven against a dead pool).cargo build --features cloudunchanged (27 pre-existingee/*.rserrors, none from this change).Notes for reviewers / follow-ups (out of scope here)
revoked_atisn't exercised end-to-end (the decision logic and cache round-trip are). A review caught and fixed a real type bug here:revoked_atisTIMESTAMP(no tz) →time::PrimitiveDateTime, notOffsetDateTime— otherwise a revoked host would have failed to decode and denied via the wrong (502) path.ClientHost.revokedAtwriter (API/UI) is still needed to use the revoke lever.Pipeline: Opus plan → Sonnet dev → Opus security review (no blockers; caught the revoked_at type bug + broadened the bypass warning) → fixes → Sonnet QA (GO).
https://claude.ai/code/session_01BgJuqEJqf7ZiUdqHWxi6bt