Skip to content

Commit 32eaa23

Browse files
authored
feat(cli,api,store): binding CRUD, multi-destination cred add, cred update (#24)
* feat(store): add UpdateBinding method with optional field updates * feat(cli): add sluice binding CRUD subcommand * feat(cli): support multiple --destination on cred add * feat(cli): add sluice cred update for value replacement * feat(api): add PATCH endpoints for binding and credential updates * chore: verify acceptance criteria for binding-cli plan * docs: document sluice binding and cred update, mark plan complete * fix: address review findings from binding-cli plan Squashed fixes from review phases 1 through 4 and codex iterations 1-10: - preserve OAuth refresh_token when not supplied (CLI + API) - sync paired allow rule when binding destination, ports, or protocols change - add EnvVar field to BindingUpdateOpts; --env-var and --protocols on update - reject duplicate binding add via case-insensitive UNIQUE index (migrations 000005, 000007) - migration aborts on conflicting binding dedup so data loss is surfaced to operators - classify API errors: 409 Conflict on duplicates, 400 only for ErrBindingValidation, 500 otherwise - wrap validation errors (port range, destination glob, protocols, env_var) with ErrBindingValidation sentinel - close TOCTOUs: AddBinding env_var check transactional; RemoveBindingWithRuleCleanup in one tx; UpdateBindingWithRuleSync transactional; PostApiCredentials/PatchApiCredentialsName acquire reloadMu - CAS-protect credential rollback against concurrent writers (vault.RollbackAdd, store.RemoveCredentialMetaCAS) - PatchApiCredentialsName and handleCredUpdate use credential_meta as authoritative type source - updatePairedRuleTx walks both cred-add and binding-add source prefixes - recompileEngine/rebuildResolver failures now return 500 to callers instead of WARN+success - return 500 on rebuildResolver failures in PostApiCredentials/DeleteApiCredentialsName - consolidated source-prefix constants into store package - deleted dead UpdateBinding/UpdateRuleDestinationBySource/SyncPairedAllowRule code - extracted parsePortsList helper into flagutil.go - updated CLAUDE.md and openapi.yaml to match new behavior * refactor(store): consolidate binding-cli migrations into 000005 The binding-cli branch added 000005, 000006, and 000007 across separate review iterations. None have shipped to production, so they should be one atomic schema change. The consolidated 000005 drops idx_bindings_env_var, detects conflicting (credential, LOWER(destination)) bindings, dedups exact duplicates with their paired allow rules, and creates the case-insensitive unique index in a single migration. * fix(store): use %w for wrapped error formatting (errorlint)
1 parent 41daecc commit 32eaa23

22 files changed

Lines changed: 8708 additions & 1039 deletions

CLAUDE.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,17 +75,27 @@ sluice mcp # start MCP gateway
7575
7676
sluice cred add <name> [--type static|oauth] [--destination host] [--ports 443] [--header Authorization] [--template "Bearer {value}"] [--env-var OPENAI_API_KEY]
7777
sluice cred add <name> --type oauth --token-url <url> --destination <host> --ports 443 [--env-var OPENAI_API_KEY]
78+
sluice cred update <name> # replace credential value (prompts via stdin, handles static and OAuth; OAuth refresh token is preserved if left blank)
7879
sluice cred list
7980
sluice cred remove <name>
8081
82+
sluice binding add <credential> --destination <host> [--ports 443] [--header Authorization] [--template "Bearer {value}"] [--env-var OPENAI_API_KEY]
83+
sluice binding list [--credential <name>]
84+
sluice binding update <id> [--destination <host>] [--ports 443] [--header Authorization] [--template "Bearer {value}"] [--protocols http,grpc] [--env-var OPENAI_API_KEY]
85+
sluice binding remove <id>
86+
8187
sluice cert generate # generate CA certificate for HTTPS MITM
8288
sluice audit verify # verify audit log hash chain integrity
8389
```
8490

85-
When `--destination` is provided, `sluice cred add` also creates an allow rule and binding in the store. When `--env-var` is provided, the phantom token is injected into the agent container as that environment variable via `docker exec` (no shared volume needed). For HTTP/WebSocket upstreams, `--command` holds the URL. Env values prefixed with `vault:` are resolved from the credential vault at upstream spawn time.
91+
When `--destination` is provided, `sluice cred add` also creates an allow rule and binding in the store. The flag may be repeated to create multiple bindings that share the same `--ports`, `--header`, and `--template` values (use `sluice binding add` afterwards for per-destination customization). When `--env-var` is provided, the phantom token is injected into the agent container as that environment variable via `docker exec` (no shared volume needed). For HTTP/WebSocket upstreams, `--command` holds the URL. Env values prefixed with `vault:` are resolved from the credential vault at upstream spawn time.
8692

8793
Two credential types: `static` (default) for API keys and `oauth` for OAuth access/refresh token pairs. OAuth credentials prompt for tokens via stdin (not CLI flags) to avoid shell history exposure.
8894

95+
`sluice cred update` uses PATCH partial-update semantics for OAuth credentials. Pressing Enter at the refresh token prompt (or omitting the second line when piping via stdin) preserves the currently stored refresh token. To explicitly clear the refresh token, update the credential again with the desired empty value through the REST API (`PATCH /api/credentials/<name>` with `"refresh_token": ""`). This prevents an access-token rotation from silently destroying the stored refresh token.
96+
97+
`sluice binding update --destination` also updates the paired auto-created allow rule (tagged `binding-add:<credential>` or `cred-add:<credential>`) so the new destination is not orphaned. If no paired rule exists (e.g. because it was manually removed), the binding destination is still updated and a warning is printed. No fallback rule is created so an operator's intentional removal is not silently reverted. `--env-var` on binding update can be used to change or clear the env var name after the initial binding was created.
98+
8999
Runtime flags: `--mcp-base-url` sets the external URL the agent uses to reach sluice's MCP gateway (e.g. `http://sluice:3000`). This is added to `SelfBypass` so sluice does not policy-check its own MCP traffic. Defaults to deriving from `--health-addr`.
90100

91101
## MCP Gateway Setup

api/openapi.yaml

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,37 @@ paths:
295295
$ref: "#/components/schemas/ErrorResponse"
296296

297297
/api/credentials/{name}:
298+
patch:
299+
operationId: patchApiCredentialsName
300+
summary: Replace the value of an existing credential
301+
tags: [credentials]
302+
parameters:
303+
- name: name
304+
in: path
305+
required: true
306+
schema:
307+
type: string
308+
requestBody:
309+
required: true
310+
content:
311+
application/json:
312+
schema:
313+
$ref: "#/components/schemas/CredentialUpdate"
314+
responses:
315+
"204":
316+
description: Credential updated
317+
"400":
318+
description: Invalid request
319+
content:
320+
application/json:
321+
schema:
322+
$ref: "#/components/schemas/ErrorResponse"
323+
"404":
324+
description: Credential not found
325+
content:
326+
application/json:
327+
schema:
328+
$ref: "#/components/schemas/ErrorResponse"
298329
delete:
299330
operationId: deleteApiCredentialsName
300331
summary: Remove a credential
@@ -352,8 +383,56 @@ paths:
352383
application/json:
353384
schema:
354385
$ref: "#/components/schemas/ErrorResponse"
386+
"409":
387+
description: Binding already exists for this credential and destination
388+
content:
389+
application/json:
390+
schema:
391+
$ref: "#/components/schemas/ErrorResponse"
355392

356393
/api/bindings/{id}:
394+
patch:
395+
operationId: patchApiBindingsId
396+
summary: Update a credential binding
397+
tags: [bindings]
398+
parameters:
399+
- name: id
400+
in: path
401+
required: true
402+
schema:
403+
type: integer
404+
format: int64
405+
requestBody:
406+
required: true
407+
content:
408+
application/json:
409+
schema:
410+
$ref: "#/components/schemas/BindingUpdate"
411+
responses:
412+
"200":
413+
description: Binding updated
414+
content:
415+
application/json:
416+
schema:
417+
$ref: "#/components/schemas/Binding"
418+
"400":
419+
description: Invalid request
420+
content:
421+
application/json:
422+
schema:
423+
$ref: "#/components/schemas/ErrorResponse"
424+
"404":
425+
description: Binding not found
426+
content:
427+
application/json:
428+
schema:
429+
$ref: "#/components/schemas/ErrorResponse"
430+
"409":
431+
description: Destination collides with an existing binding for the same credential
432+
content:
433+
application/json:
434+
schema:
435+
$ref: "#/components/schemas/ErrorResponse"
357436
delete:
358437
operationId: deleteApiBindingsId
359438
summary: Remove a credential binding
@@ -794,6 +873,10 @@ components:
794873

795874
CreateBindingRequest:
796875
type: object
876+
description: |
877+
Creates a binding between a credential and a destination. The server
878+
rejects duplicates: a binding on the same (credential, destination)
879+
pair may only exist once. Repeated POSTs return 409 Conflict.
797880
required: [destination, credential]
798881
properties:
799882
destination:
@@ -816,6 +899,66 @@ components:
816899
type: string
817900
description: Environment variable name for phantom token injection
818901

902+
BindingUpdate:
903+
type: object
904+
description: |
905+
Partial update for a binding. At least one field must be set in the
906+
request body, otherwise the server returns 400 Bad Request (matching
907+
the CLI). Only fields that are present are modified. Setting
908+
`destination` to a non-empty value also updates the paired
909+
auto-created allow rule (tagged with `binding-add:<credential>` or
910+
`cred-add:<credential>`) so the new destination is not orphaned. If
911+
the paired rule has been removed manually, the binding destination
912+
is still updated and a warning is logged (no fallback rule is
913+
created). Passing an empty array for `ports` or `protocols` clears
914+
them. Passing an empty string for `header`, `template`, or `env_var`
915+
clears the field. Updating `destination` to a value that collides
916+
with another binding on the same credential returns 409 Conflict.
917+
properties:
918+
destination:
919+
type: string
920+
ports:
921+
type: array
922+
items:
923+
type: integer
924+
header:
925+
type: string
926+
template:
927+
type: string
928+
protocols:
929+
type: array
930+
items:
931+
type: string
932+
env_var:
933+
type: string
934+
description: Environment variable name for phantom token injection
935+
936+
CredentialUpdate:
937+
type: object
938+
description: |
939+
New value for an existing credential. For static credentials, set
940+
`value`. For OAuth credentials, set `access_token` and optionally
941+
`refresh_token`. The existing `token_url` is preserved.
942+
943+
PATCH semantics: omitting `refresh_token` for an OAuth credential
944+
preserves the currently stored refresh token. To explicitly clear
945+
the refresh token, send an empty string (`"refresh_token": ""`).
946+
This prevents clients that rotate only the access token from
947+
silently destroying the stored refresh token.
948+
properties:
949+
value:
950+
type: string
951+
description: New secret value (for static credentials)
952+
access_token:
953+
type: string
954+
description: New OAuth access token (for oauth credentials)
955+
refresh_token:
956+
type: string
957+
description: |
958+
New OAuth refresh token (for oauth credentials). Omit to
959+
preserve the existing refresh token. Send an empty string to
960+
clear it.
961+
819962
MCPUpstream:
820963
type: object
821964
required: [id, name, command]

0 commit comments

Comments
 (0)