Skip to content

feat(gateway): enforce cert-identity ↔ agent-token tenant binding (Phase 5) - #5

Open
marcorivm wants to merge 2 commits into
phase3/relay-modefrom
phase5/cert-token-binding
Open

feat(gateway): enforce cert-identity ↔ agent-token tenant binding (Phase 5)#5
marcorivm wants to merge 2 commits into
phase3/relay-modefrom
phase5/cert-token-binding

Conversation

@marcorivm

Copy link
Copy Markdown
Member

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 ClientHost resolved from the cert's spiffe://onecli/host/<id> identity has projectId == token.project_id (+ org consistency when the host's org is non-null, + the host is not revoked). The ClientHost row is the allowlist entry — deny-unless-a-matching-non-revoked-row.

Rollout (safe by construction)

GATEWAY_BINDING_ENFORCEMENT = off | log | enforce, default off — 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

  • Both proxy paths enforce. One shared enforce_binding helper is called from handle_connect AND handle_http_proxy, each after tenant resolution and before any upstream path — the absolute-form route is not a bypass.
  • on_mtls is 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.
  • Fail-closed. A DB/cache lookup error in enforce denies (502, retryable) — never allows. Permanent verdicts (mismatch / unknown host / revoked / missing identity) return 403. Justified: token resolution already 502s on any DB error before the binding read, so fail-closed adds ~no new availability loss.
  • Untrusted identity at the DB layer — parameterized $1 lookup on the unique spiffe_uri column, no interpolation.
  • No secret leakage — the 403 body is {"error":"identity_not_permitted"}; audit logs carry spiffe/host-project/token-project/org/agent-id/decision, never the token.
  • Honors ClientHost.revokedAt as 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 + fmt clean. Exhaustive unit coverage of the decision table and every deny branch; enforce_binding off/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 cloud unchanged (27 pre-existing ee/*.rs errors, none from this change).

Notes for reviewers / follow-ups (out of scope here)

  • No live-DB test tier exists in the gateway crate, so the sqlx wire-decode of a non-null revoked_at isn't exercised end-to-end (the decision logic and cache round-trip are). A review caught and fixed a real type bug here: revoked_at is TIMESTAMP (no tz) → time::PrimitiveDateTime, not OffsetDateTime — otherwise a revoked host would have failed to decode and denied via the wrong (502) path.
  • A ClientHost.revokedAt writer (API/UI) is still needed to use the revoke lever.
  • Enforcement is only meaningful if the plaintext listener is restricted to loopback/trusted network — the gateway warns at startup when enforce is on and the plain listener is bound to a non-loopback address.

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

…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
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