Skip to content

gateway: mode is all-or-nothing — no way to exclude a harness or a user from it #120

Description

@cubeorgdev

What problem does this solve?

Gateway mode is a single deployment-wide switch. gateway.type from
blue.gateway.yaml / HARNESS_GATEWAY_TYPE becomes the top-level gateway block via
apply_deployment_gateway_policy (services/control-api/src/lib.rs:718), and from there
every allowed harness for every user is routed through the gateway. The docs state the
limit outright: "The mode is selected once for the deployment policy and applies to every
allowed coding agent" (apps/docs/next/concepts/gateway-mode.mdx).

Two things operators cannot express today:

  • Exclude a harness. An org that routes Kimi and OpenCode through its LiteLLM gateway
    still has to route Claude Code and Codex through it. Teams that hold Anthropic or OpenAI
    enterprise seats want those two left on their own credentials and untouched by the proxy,
    while everything else stays governed. It is all four or none.
  • Exclude a user. There is no way to keep one person off the gateway — a contractor on
    their own key, or a staged rollout where the gateway is enabled for the deployment but
    only a pilot group should route through it.

The only existing off-switch is the client's /direct command, which persists
force_governance_only in blue.toml (crates/gh-cli/src/commands.rs:3452). That is
per-workstation, user-controlled, and invisible to the server — an administrator cannot use
it to exclude a harness or a person, and a user can always turn it back off.

Enabling the gateway and then excluding parts of it is also not expressible in YAML:
governance_seed_yaml (:2170) strips governance.gateway from the deploy document and
re-inserts the deployment policy, and apply_deployment_gateway_policy overwrites
config.gateway on every admin save (:6240).

Proposed solution

Two subtractive overrides on top of the existing switch. Neither can ever enable gateway
mode — that stays the server's sole authority, and the "client may only downgrade"
invariant is preserved and extended to the policy layer.

Effective precedence, all subtractive:
deployment gateway absent > client too old > per-user exclusion > per-harness policy > /direct.

A. Per-harness, org-level — a configuration control

Add gateway_disabled: Option<bool> to HarnessPolicy (crates/gh-service/src/schema.rs:113).
Absent/false inherits the deployment gateway.

  • Not named gateway: reject_extension_owned_yaml (:6149) already rejects
    harnesses.<h>.gateway as "a global policy", with a test at :10857.
  • Not inside GatewayConfig: it is #[serde(deny_unknown_fields)] (schema.rs:492), so a
    new field there makes every installed CLI fail to deserialize /governance-config
    fatal, with no cache fallback (crates/gh-service/src/client.rs:209). HarnessPolicy
    ignores unknown fields, so old clients keep parsing.
  • Serialize it explicitly rather than skip_serializing_if: un-excluding must produce
    false, not an absent key, because merge_governance_value (:2690) three-way-merges the
    deploy baseline against the stored document and "deleted here, absent there" is ambiguous.
  • Owner: PUT /admin/harnesses/:harness/managed-config (:7814) — already per-harness,
    already does optimistic concurrency, and does not call apply_deployment_gateway_policy.
    Not /admin/governance-extensions, whose replace_extensions (:7967) is about
    packages/MCP.
  • Client: pass gateway: None for an excluded harness at crates/gh-agent/src/lib.rs:234
    and :242. WriteOptions.gateway_enabled (commands.rs:57) stays exactly as it is — it is
    the /direct override and is already separate from the deployment signal.

B. Per-user — a security control, enforced server-side

New table gateway_user_exclusions(organization_id, user_id, harness text NULL, created_by, created_at), harness IS NULL meaning a full exclusion. Validation mirrors
validate_package_audiences (:7995), including its nuance of rejecting newly added
inactive users while retaining already-present ones — otherwise suspending a user makes
every later governance save fail.

Enforced at three points, in this order of importance:

  1. resolve_gateway_key (:4288) — the only real chokepoint. It currently checks
    deployment settings, a live gateway_auth_sessions row, and expiry, and never reads the
    governance document. Without a check here an excluded user keeps working until their
    inference JWT expires — up to 12 h (gateway_inference_token_ttl_seconds,
    deploy/blue.gateway.yaml:17). Cache the lookup on (user_id, revision) and invalidate
    over the existing gateway_cache_events channel.
  2. ensure_managed_gateway_key (:3844) — return the existing enabled: false shape, which
    gateway_access_requires_provisioning (commands.rs:1156) already treats as
    authoritative. Otherwise POST /gateway/key/ensure (:4169) stays callable by the
    excluded user under their own governance:read scope.
  3. personalize_gateway_config (:4988) — strip config.gateway and skip minting.

Ordering point 3 before the mint is load-bearing: renew_gateway_auth_session
(services/control-api/src/gateway_auth.rs:40) deliberately reactivates revoked sessions, and
it is reached only from mint_gateway_inference_token. Make that an explicitly tested
invariant or an exclusion un-does itself on the next poll.

On transition into exclusion, revoke the user's gateway_auth_sessions rows so running
agents are cut in seconds rather than in ≤12 h, and mint a governance revision so
/governance-config/events (:5140) fans out — it streams only revision signals, so a
change that mints no revision is invisible until the 300 s TTL. Retain the upstream key
rather than deleting the gateway_key_selections row, so un-excluding preserves
source_key_hash and request-log profile_id continuity.

Old clients

Do not bump CONTRACT_VERSION. stamp_version_aware_client_floor (:8064) sets
contract_version = CONTRACT_VERSION for every gateway deployment (:8107, gated only on
config.gateway.is_some()), so bumping it to 4 426s every installed CLI on every gateway
tenant whether or not they use this feature.

Instead, personalize_gateway_config should strip gateway entirely for a client that does
not advertise a gateway_harness_scope capability — an old CLI degrades to governance-only
instead of silently ignoring an exclusion. Fail-closed, and no new blocking capability
slug; #101 is already about required_capabilities failures producing unreadable errors.

Surfaces

  • gateway_status (:3571) maps every allowed_harnesses entry to the gateway kind
    (:3580). GatewayHarnessStatus needs a disabled_by: "policy" | "user" | null. Keep
    enabled deployment-scoped — gateway/page.tsx:159 and dashboard-sidebar.tsx:104 gate on
    it, and an excluded admin still needs the request-logs page.
  • Client sites that must become per-harness: the 8 gateway predicate sites in commands.rs
    should collapse into one helper. Seven spell out
    config.gateway.is_some() && !force_governance_only (2173, 2684, 2836, 2849, 2901, 3592, 3672); the remaining two are already inconsistent with them — gateway_available (:2920)
    omits the /direct term, and gateway_token_expires_at (:2892) has no predicate at all,
    so an excluded harness would still show the expiring-token countdown for a token it never
    received.
  • AppliedState.gateway_enabled (commands.rs:1528) is one bool for the whole state file,
    written for the selected harness. Move it into the per-harness maps and bump
    APPLIED_STATE_SCHEMA_VERSION.
  • Configs are written one harness at a time (apply_loaded scopes allowed_harnesses to the
    target, :3516), so an excluded harness keeps its stale gateway base_url on disk until it
    is next launched. Harmless — the token is launch-scoped and never written to disk — but it
    should be stated rather than discovered.
  • Contract: deploy/contract/governance.openapi.yaml and apps/docs/openapi/next.yaml must
    stay byte-identical (apps/docs/scripts/check-contract.mjs).
  • Docs under apps/docs/next/: concepts/gateway-mode.mdx (contains the sentence this
    falsifies), reference/governance-config.mdx, deployment/blue-yaml.mdx,
    admin/gateway-access.mdx, admin/user-management.mdx, cli/commands.mdx. Leave
    apps/docs/0.1.0/ alone — it is a frozen snapshot checked by check-release-snapshot.mjs.

Scope note

A is a small change confined to one struct, one admin endpoint, one dashboard editor and one
gh-agent loop. B touches a new table, three enforcement points, and the credential/session
revocation lifecycle. Best delivered as two PRs against this issue, A first; happy to split
into two issues if the team prefers. Both involve dashboard surfaces, so they need the
core-team design review CONTRIBUTING requires.

Framing: A is a configuration control — enforcement is client-side, and
gateway_request_logs.harness comes from the untrusted x-harness-agent header
(services/inference-proxy/src/main.rs:1431), so it cannot prove a harness-level bypass. B is
a security control, enforced at resolve_gateway_key.

Acceptance criteria

Contract

  • CONTRACT_VERSION stays 3; no fixture, deploy/*.yaml, or docs page changes it.
  • A client not advertising gateway_harness_scope gets gateway: null, no provisioning,
    no minted JWT, and launches in governance-only — not a 426 and not a serde error.
  • A document with an unknown HarnessPolicy field still deserializes on an old client.
  • deploy/contract/governance.openapi.yaml and apps/docs/openapi/next.yaml match.

Per-harness

  • gateway_disabled: true on one harness: no gateway wiring written for it, every other
    allowed harness unchanged.
  • Round-trips through /admin/harnesses/:harness/managed-config,
    /admin/governance-extensions, /admin/governance-config, and a control-api restart.
  • apply_deployment_gateway_policy does not clear it.
  • /direct is hidden for an excluded harness and no token countdown appears.
  • gateway_status.harnesses flags exclusions; gateway_status.enabled stays
    deployment-scoped.
  • The 16 existing goldens in crates/gh-config/tests/golden/ are unchanged — an excluded
    harness renders the existing governance plan.

Per-user

  • An excluded user's /governance-config has gateway: null, with no provisioning and no
    session renewal.
  • POST /internal/gateway/resolve denies an excluded user holding a valid unexpired JWT
    and a live session row.
  • POST /gateway/key/ensure returns enabled: false and makes no provisioner call.
  • Excluding a user with a running agent cuts inference in seconds, not in ≤12 h.
  • Un-excluding restores access without re-provisioning (profile_id continuity held).
  • An exclusion change emits an SSE event on /governance-config/events.
  • No governance:read-scoped endpoint lets a user un-exclude themselves.

Validation

  • Exclusion on an unsupported harness → 400 via the existing supported_harness path.
  • Every allowed harness excluded → saves with a warning, 500s no read path.
  • Per-user exclusion of an out-of-org user → 400; of an already-excluded inactive user →
    still saveable.

Tests

  • lib.rs tests: stamping with exclusions, validate_complete_governance matrix,
    personalize_gateway_config stripping, resolve_gateway_key denial. The existing
    reject_extension_owned_yaml assertion at :10857 must still pass.
  • tests/e2e/specs/journey.spec.ts:180 gains a sibling: exclude codex, assert no gateway
    base_url in .codex/blue.config.toml, assert /direct is absent.
  • tests/e2e/specs/contract-surface.spec.ts gains the excluded-user 403.

Before submitting

  • I searched existing issues and this isn't a duplicate.
  • I confirmed this doesn't already exist elsewhere in Blue.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions