diff --git a/README.md b/README.md index a940a84..eabdabf 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Agent Vault takes a different approach: **Agent Vault never reveals vault-stored - **Brokered access, not retrieval** - Your agent gets a token and a proxy URL. It sends requests to `proxy/{host}/{path}` and Agent Vault authenticates them. Credentials stored in the vault are never returned to the agent. [Learn more](https://docs.agent-vault.dev/learn/security) - **Works with any agent** - Custom Python/TypeScript agents, sandboxed processes, coding agents (Claude Code, Cursor, Codex), anything that can make HTTP requests. [Learn more](https://docs.agent-vault.dev/quickstart) -- **Self-service access** - Agents discover available services at runtime and [propose access](https://docs.agent-vault.dev/learn/proposals) for anything missing. You review and approve in your browser with one click. +- **Self-service access** - Agents discover available services at runtime and [propose access](https://docs.agent-vault.dev/learn/proposals) for anything missing. You review and approve in your browser with one click. Any service can be toggled on/off without losing its configuration — disabled services return `403 service_disabled` until re-enabled. - **Encrypted at rest** - Credentials are encrypted with AES-256-GCM using a random data encryption key (DEK). An optional master password wraps the DEK via Argon2id — change the password without re-encrypting credentials. Passwordless mode available for PaaS deploys. [Learn more](https://docs.agent-vault.dev/learn/credentials) - **Multi-user, multi-vault** - Role-based access control with instance and vault-level [permissions](https://docs.agent-vault.dev/learn/permissions). Invite teammates, scope agents to specific [vaults](https://docs.agent-vault.dev/learn/vaults), and audit everything. diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index a8e1368..559c15c 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -189,7 +189,7 @@ func TestServiceSubcommandsRegistered(t *testing.T) { registered[c.Name()] = true } - expected := []string{"list", "set", "add", "remove", "clear"} + expected := []string{"list", "set", "add", "enable", "disable", "remove", "clear"} for _, name := range expected { if !registered[name] { t.Errorf("expected service subcommand %q to be registered, but it was not", name) diff --git a/cmd/service.go b/cmd/service.go index ced6e73..9daf72f 100644 --- a/cmd/service.go +++ b/cmd/service.go @@ -187,6 +187,10 @@ File mode (upsert, not replace-all): if desc, _ := cmd.Flags().GetString("description"); desc != "" { svc.Description = &desc } + if disabled, _ := cmd.Flags().GetBool("disabled"); disabled { + f := false + svc.Enabled = &f + } services = []broker.Service{svc} } @@ -273,6 +277,63 @@ var serviceRemoveCmd = &cobra.Command{ }, } +var serviceEnableCmd = &cobra.Command{ + Use: "enable ", + Short: "Enable a service so proxy traffic to the host resumes", + Long: `Re-enable a previously disabled service. Idempotent — no error if already enabled.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return patchServiceEnabled(cmd, args[0], true) + }, +} + +var serviceDisableCmd = &cobra.Command{ + Use: "disable ", + Short: "Disable a service so proxy requests to the host return 403", + Long: `Disable a service while preserving its configuration. Agents proxying +to the host receive 403 with error code "service_disabled" until the +service is re-enabled. Idempotent.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return patchServiceEnabled(cmd, args[0], false) + }, +} + +func patchServiceEnabled(cmd *cobra.Command, host string, enabled bool) error { + vault := resolveVault(cmd) + + sess, err := ensureSession() + if err != nil { + return err + } + + body, err := json.Marshal(map[string]bool{"enabled": enabled}) + if err != nil { + return err + } + + reqURL := fmt.Sprintf("%s/v1/vaults/%s/services/%s", sess.Address, vault, url.PathEscape(host)) + respBody, err := doAdminRequestWithBody("PATCH", reqURL, sess.Token, body) + if err != nil { + return err + } + + var resp struct { + Host string `json:"host"` + Enabled bool `json:"enabled"` + } + if err := json.Unmarshal(respBody, &resp); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + + verb := "disabled" + if resp.Enabled { + verb = "enabled" + } + fmt.Fprintf(cmd.OutOrStdout(), "%s Service %s: %s\n", successText("✓"), verb, resp.Host) + return nil +} + // loadServicesFromFile reads and validates a broker config from a YAML file path ("-" for stdin). func loadServicesFromFile(filePath, vault string) ([]broker.Service, error) { var data []byte @@ -324,6 +385,7 @@ func init() { serviceAddCmd.Flags().String("api-key-key", "", "Credential key for api-key auth") serviceAddCmd.Flags().String("api-key-header", "", "Header name for api-key (default Authorization)") serviceAddCmd.Flags().String("api-key-prefix", "", "Prefix for api-key value") + serviceAddCmd.Flags().Bool("disabled", false, "Create the service in a disabled state (proxy traffic returns 403 until enabled)") // service remove flags serviceRemoveCmd.Flags().Bool("yes", false, "Skip confirmation prompt") @@ -331,6 +393,8 @@ func init() { serviceCmd.AddCommand(serviceListCmd) serviceCmd.AddCommand(serviceSetCmd) serviceCmd.AddCommand(serviceAddCmd) + serviceCmd.AddCommand(serviceEnableCmd) + serviceCmd.AddCommand(serviceDisableCmd) serviceCmd.AddCommand(serviceRemoveCmd) serviceCmd.AddCommand(serviceClearCmd) vaultCmd.AddCommand(serviceCmd) diff --git a/cmd/skill_cli.md b/cmd/skill_cli.md index 894cbcd..7022661 100644 --- a/cmd/skill_cli.md +++ b/cmd/skill_cli.md @@ -127,8 +127,9 @@ Flag-driven auth flags by type: Other flags: `--description` (service description), `--user-message` (shown on browser approval page), `--credential KEY=description` (repeatable). Key fields (JSON mode): -- `services[].action` -- `"set"` (upsert, needs `host` + `auth`) or `"delete"` (needs `host` only) +- `services[].action` -- `"set"` (upsert, needs `host` + `auth` **or** an `enabled` change) or `"delete"` (needs `host` only) - `services[].auth` -- authentication config. Types: `bearer` (`token`), `basic` (`username`, optional `password`), `api-key` (`key` + `header`, optional `prefix`), `custom` (`headers` map with `{{ KEY }}` templates), `passthrough` (no credential fields) +- `services[].enabled` -- optional boolean. Omitted means "enabled" for new services. A `"set"` proposal may supply `enabled` alone (no `auth`) to toggle an existing service's state without replacing its auth config -- useful for staged rollouts - `credentials[].action` -- `"set"` (omit `value` for human to supply; include `value` to store back) or `"delete"` - `credentials` -- only declare credentials not already in `available_credentials`. Every credential referenced in auth configs must resolve to a slot or existing credential (400 otherwise) - `message` -- developer-facing explanation; `user_message` -- shown on the browser approval page @@ -189,7 +190,8 @@ Prints the raw value to stdout (pipe-friendly). Useful for configuration tasks w ## Error Handling - 401: Invalid or expired token -- check `AGENT_VAULT_SESSION_TOKEN` -- 403: Host not allowed -- create a proposal +- 403 `forbidden`: Host not allowed -- create a proposal +- 403 `service_disabled`: Host is configured but currently disabled by an operator. Don't create a new proposal; surface the error to the user so they can re-enable it (UI toggle, or `agent-vault vault service enable `) - 429: Too many pending proposals -- wait for review - 502: Missing credential or upstream unreachable, tell user a credential may need to be added diff --git a/cmd/skill_http.md b/cmd/skill_http.md index 7afaf5f..90dc486 100644 --- a/cmd/skill_http.md +++ b/cmd/skill_http.md @@ -117,8 +117,9 @@ Content-Type: application/json ``` Key fields: -- `services[].action` -- `"set"` (upsert, needs `host` + `auth`) or `"delete"` (needs `host` only) +- `services[].action` -- `"set"` (upsert, needs `host` + `auth` **or** an `enabled` change) or `"delete"` (needs `host` only) - `services[].auth` -- authentication config. Types: `bearer` (`token`), `basic` (`username`, optional `password`), `api-key` (`key` + `header`, optional `prefix`), `custom` (`headers` map with `{{ KEY }}` templates), `passthrough` (no credential fields) +- `services[].enabled` -- optional boolean. Omitted means "enabled" for new services. A `"set"` proposal may supply `enabled` alone (no `auth`) to flip an existing service's state without replacing its auth config -- useful for staged rollouts where the operator wires credentials before flipping traffic on - `credentials[].action` -- `"set"` (omit `value` for human to supply; include `value` to store back) or `"delete"` - `credentials` -- only declare credentials not already in `available_credentials`. Every credential referenced in auth configs must resolve to a slot or existing credential (400 otherwise) - `message` -- developer-facing explanation; `user_message` -- shown on the browser approval page @@ -178,7 +179,8 @@ Content-Type: application/json ## Error Handling - 401: Invalid or expired token -- check `AGENT_VAULT_SESSION_TOKEN` -- 403: Host not allowed -- create a proposal +- 403 `forbidden`: Host not allowed -- create a proposal +- 403 `service_disabled`: Host is configured but currently disabled by an operator. Don't create a new proposal; surface the error to the user so they can re-enable it - 429: Too many pending proposals -- wait for review - 502: Missing credential or upstream unreachable, tell user a credential may need to be added diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 1a050eb..29ecb73 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -422,8 +422,35 @@ description: "Complete reference for all Agent Vault CLI commands." | `--api-key-key` | | Credential key for api-key auth | | `--api-key-header` | `Authorization` | Header name for api-key | | `--api-key-prefix` | | Prefix for api-key value | + | `--disabled` | `false` | Create the service in a disabled state (proxy traffic returns 403 until enabled) | The `passthrough` auth type accepts no credential flags; Agent Vault allowlists the host and forwards the client's request headers unchanged, stripping only hop-by-hop and broker-scoped headers (`X-Vault`, `Proxy-Authorization`). + + New services are **enabled by default**. Pass `--disabled` to create the service in a disabled state, or use `agent-vault vault service disable ` after creation. + + + + ```bash + agent-vault vault service enable [flags] + ``` + + Enable a service so proxy traffic to the host resumes. Idempotent — no error if the service is already enabled. + + | Flag | Default | Description | + |------|---------|-------------| + | `--vault` | `default` | Target vault | + + + + ```bash + agent-vault vault service disable [flags] + ``` + + Disable a service while preserving its configuration. Agents proxying to the host receive `403` with error code `service_disabled` until the service is re-enabled. Idempotent. + + | Flag | Default | Description | + |------|---------|-------------| + | `--vault` | `default` | Target vault | diff --git a/internal/broker/broker.go b/internal/broker/broker.go index 56662b3..9f373a9 100644 --- a/internal/broker/broker.go +++ b/internal/broker/broker.go @@ -15,12 +15,25 @@ type Config struct { } // Service defines a host-matching service with credential attachment. +// +// Enabled is a nullable toggle. nil means "not set" and is treated as +// enabled so existing persisted services (which predate this field) stay +// live after upgrade. Callers should use IsEnabled() rather than +// dereferencing the pointer. type Service struct { Host string `yaml:"host" json:"host"` Description *string `yaml:"description,omitempty" json:"description"` + Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` Auth Auth `yaml:"auth" json:"auth"` } +// IsEnabled reports whether the service should serve proxy traffic. A +// nil Enabled field (missing from the stored JSON) is treated as enabled +// so services persisted before this field existed stay live after upgrade. +func (s *Service) IsEnabled() bool { + return s.Enabled == nil || *s.Enabled +} + // Auth describes how credentials are attached for a broker service. // Each service must specify a Type and the fields relevant to that type. // diff --git a/internal/brokercore/brokercore.go b/internal/brokercore/brokercore.go index 32699e3..ce5f286 100644 --- a/internal/brokercore/brokercore.go +++ b/internal/brokercore/brokercore.go @@ -224,6 +224,9 @@ func WriteInjectError(w http.ResponseWriter, err error, targetHost, vaultName, b switch { case errors.Is(err, ErrServiceNotFound): WriteForbiddenHint(w, targetHost, vaultName, baseURL) + case errors.Is(err, ErrServiceDisabled): + writeProxyErrorWithHelp(w, http.StatusForbidden, "service_disabled", + fmt.Sprintf("Broker service matching host %q in vault %q is currently disabled", targetHost, vaultName), baseURL) case errors.Is(err, ErrCredentialMissing): writeProxyErrorWithHelp(w, http.StatusBadGateway, "credential_not_found", "A required credential could not be resolved; check vault configuration", baseURL) diff --git a/internal/brokercore/credential.go b/internal/brokercore/credential.go index f13c30b..67704e1 100644 --- a/internal/brokercore/credential.go +++ b/internal/brokercore/credential.go @@ -86,6 +86,9 @@ func (p *StoreCredentialProvider) Inject(ctx context.Context, vaultID, targetHos if matched == nil { return nil, ErrServiceNotFound } + if !matched.IsEnabled() { + return nil, ErrServiceDisabled + } // Passthrough services opt out of credential injection entirely. No // vault read, no CredentialKeys (nothing to resolve). The ingress diff --git a/internal/brokercore/credential_test.go b/internal/brokercore/credential_test.go index 710f6f6..44c5cca 100644 --- a/internal/brokercore/credential_test.go +++ b/internal/brokercore/credential_test.go @@ -286,6 +286,63 @@ func TestInject_Passthrough(t *testing.T) { } } +func TestInject_ServiceDisabled(t *testing.T) { + key32 := make32(0xEE) + disabled := false + f := newFakeCredStore() + f.setServices(t, "v1", []broker.Service{{ + Host: "api.example.com", + Enabled: &disabled, + Auth: broker.Auth{Type: "bearer", Token: "TOK"}, + }}) + f.setCred(t, key32, "v1", "TOK", "x") + + p := NewStoreCredentialProvider(f, key32) + _, err := p.Inject(context.Background(), "v1", "api.example.com") + if !errors.Is(err, ErrServiceDisabled) { + t.Fatalf("expected ErrServiceDisabled, got %v", err) + } + if f.getCredentialCalls != 0 { + t.Fatalf("expected no credential lookup when disabled, got %d calls", f.getCredentialCalls) + } +} + +func TestInject_ServiceDisabled_Passthrough(t *testing.T) { + disabled := false + f := newFakeCredStore() + f.setServices(t, "v1", []broker.Service{{ + Host: "api.example.com", + Enabled: &disabled, + Auth: broker.Auth{Type: "passthrough"}, + }}) + p := NewStoreCredentialProvider(f, make32(0xEF)) + _, err := p.Inject(context.Background(), "v1", "api.example.com") + if !errors.Is(err, ErrServiceDisabled) { + t.Fatalf("expected ErrServiceDisabled for disabled passthrough, got %v", err) + } +} + +func TestInject_EnabledExplicitTrue(t *testing.T) { + key32 := make32(0xF0) + enabled := true + f := newFakeCredStore() + f.setServices(t, "v1", []broker.Service{{ + Host: "api.example.com", + Enabled: &enabled, + Auth: broker.Auth{Type: "bearer", Token: "TOK"}, + }}) + f.setCred(t, key32, "v1", "TOK", "v") + + p := NewStoreCredentialProvider(f, key32) + res, err := p.Inject(context.Background(), "v1", "api.example.com") + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if res.Headers["Authorization"] != "Bearer v" { + t.Fatalf("got %q", res.Headers["Authorization"]) + } +} + func TestInject_PassthroughPortStripped(t *testing.T) { f := newFakeCredStore() f.setServices(t, "v1", []broker.Service{{ diff --git a/internal/brokercore/errors.go b/internal/brokercore/errors.go index 225e4b6..8332579 100644 --- a/internal/brokercore/errors.go +++ b/internal/brokercore/errors.go @@ -36,4 +36,10 @@ var ( // service's auth config is not set or could not be decrypted. Callers // surface 502 so agents retry only after the credential is provisioned. ErrCredentialMissing = errors.New("brokercore: referenced credential missing or undecryptable") + + // ErrServiceDisabled means a configured broker service matched the + // target host but has been toggled off by an operator. Distinct from + // ErrServiceNotFound so agents can tell "configured but off" from "not + // configured". Callers surface 403 with error code "service_disabled". + ErrServiceDisabled = errors.New("brokercore: broker service is disabled") ) diff --git a/internal/proposal/merge.go b/internal/proposal/merge.go index 526d257..d9bb825 100644 --- a/internal/proposal/merge.go +++ b/internal/proposal/merge.go @@ -35,14 +35,17 @@ func MergeServices(existing []broker.Service, proposed []Service) ([]broker.Serv delete(hostIndex, p.Host) default: // ActionSet: upsert - svc := toBrokerService(p) - if idx, exists := hostIndex[p.Host]; exists { - // Replace existing service in place. - merged[idx] = svc - } else { - // Append new service. + idx, exists := hostIndex[p.Host] + switch { + case exists && p.Auth == nil && p.Enabled != nil: + // Enable/disable-only change on an existing service: + // preserve Auth and Description, overlay just the flag. + merged[idx].Enabled = p.Enabled + case exists: + merged[idx] = toBrokerService(p) + default: hostIndex[p.Host] = len(merged) - merged = append(merged, svc) + merged = append(merged, toBrokerService(p)) } } } @@ -70,6 +73,7 @@ func toBrokerService(p Service) broker.Service { svc := broker.Service{ Host: p.Host, Description: desc, + Enabled: p.Enabled, } if p.Auth != nil { svc.Auth = *p.Auth diff --git a/internal/proposal/merge_test.go b/internal/proposal/merge_test.go index 3bce009..6e54c27 100644 --- a/internal/proposal/merge_test.go +++ b/internal/proposal/merge_test.go @@ -33,6 +33,30 @@ func TestMergeServicesSetAppend(t *testing.T) { } } +func TestMergeServicesSetEnabledOnlyPreservesAuth(t *testing.T) { + disabled := false + existing := []broker.Service{ + {Host: "api.stripe.com", Auth: broker.Auth{Type: "bearer", Token: "OLD"}}, + } + proposed := []Service{ + {Action: ActionSet, Host: "api.stripe.com", Enabled: &disabled}, + } + + merged, warnings := MergeServices(existing, proposed) + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + if len(merged) != 1 { + t.Fatalf("expected 1 service, got %d", len(merged)) + } + if merged[0].Auth.Token != "OLD" { + t.Fatalf("expected Auth preserved (token OLD), got %q", merged[0].Auth.Token) + } + if merged[0].Enabled == nil || *merged[0].Enabled != false { + t.Fatalf("expected Enabled=false, got %v", merged[0].Enabled) + } +} + func TestMergeServicesSetReplacesExisting(t *testing.T) { existing := []broker.Service{ {Host: "api.stripe.com", Auth: broker.Auth{Type: "bearer", Token: "OLD"}}, diff --git a/internal/proposal/proposal.go b/internal/proposal/proposal.go index 008a59e..201325b 100644 --- a/internal/proposal/proposal.go +++ b/internal/proposal/proposal.go @@ -23,10 +23,16 @@ const ( ) // Service is a proposed broker service change. +// +// For "set" actions, at least one of Auth or Enabled must be specified. +// When Enabled is provided without Auth and the host already exists, +// the merge preserves the existing service's Auth/Description and +// overlays only the Enabled flag — this is the enable/disable flow. type Service struct { Action Action `json:"action"` Host string `json:"host"` Description string `json:"description,omitempty"` + Enabled *bool `json:"enabled,omitempty"` Auth *broker.Auth `json:"auth,omitempty"` } diff --git a/internal/proposal/validate.go b/internal/proposal/validate.go index 2de79a0..cac2423 100644 --- a/internal/proposal/validate.go +++ b/internal/proposal/validate.go @@ -133,11 +133,13 @@ func Validate(services []Service, credentials []CredentialSlot) error { return fmt.Errorf("service %d: description too long (max %d characters)", i, MaxDescriptionLen) } if s.Action == ActionSet { - if s.Auth == nil { - return fmt.Errorf("service %d: auth is required for set action", i) + if s.Auth == nil && s.Enabled == nil { + return fmt.Errorf("service %d: set action requires auth or enabled change", i) } - if err := s.Auth.Validate(); err != nil { - return fmt.Errorf("service %d: %w", i, err) + if s.Auth != nil { + if err := s.Auth.Validate(); err != nil { + return fmt.Errorf("service %d: %w", i, err) + } } } } diff --git a/internal/proposal/validate_test.go b/internal/proposal/validate_test.go index c8a3014..695b55f 100644 --- a/internal/proposal/validate_test.go +++ b/internal/proposal/validate_test.go @@ -45,8 +45,16 @@ func TestValidateEmptyHost(t *testing.T) { func TestValidateMissingAuthForSet(t *testing.T) { services := []Service{{Action: ActionSet, Host: "example.com"}} err := Validate(services, nil) - if err == nil || !strings.Contains(err.Error(), "auth is required") { - t.Fatalf("expected auth required error, got %v", err) + if err == nil || !strings.Contains(err.Error(), "auth or enabled") { + t.Fatalf("expected auth-or-enabled required error, got %v", err) + } +} + +func TestValidateEnabledOnlySetValid(t *testing.T) { + disabled := false + services := []Service{{Action: ActionSet, Host: "example.com", Enabled: &disabled}} + if err := Validate(services, nil); err != nil { + t.Fatalf("expected enable-only set to validate, got %v", err) } } diff --git a/internal/server/handle_services.go b/internal/server/handle_services.go index 286d9e1..5e408f7 100644 --- a/internal/server/handle_services.go +++ b/internal/server/handle_services.go @@ -238,6 +238,85 @@ func (s *Server) handleServiceRemove(w http.ResponseWriter, r *http.Request) { }) } +// handleServicePatch applies a partial update to a single service, +// keyed by host. Today only the `enabled` field is patchable — other +// fields change through the existing POST/PUT upsert/set flow so there +// is a single code path for validation of auth configs. Admin-only. +func (s *Server) handleServicePatch(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + name := r.PathValue("name") + + ns, err := s.store.GetVault(ctx, name) + if err != nil || ns == nil { + jsonError(w, http.StatusNotFound, fmt.Sprintf("Vault %q not found", name)) + return + } + + if _, err := s.requireVaultAdmin(w, r, ns.ID); err != nil { + return + } + + host := r.PathValue("host") + if host == "" { + jsonError(w, http.StatusBadRequest, "Host is required") + return + } + + var req struct { + Enabled *bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "Invalid request body") + return + } + if req.Enabled == nil { + jsonError(w, http.StatusBadRequest, "At least one patchable field is required (enabled)") + return + } + + bc, err := s.store.GetBrokerConfig(ctx, ns.ID) + if err != nil || bc == nil { + jsonError(w, http.StatusNotFound, fmt.Sprintf("Service not found for host %q", host)) + return + } + + var services []broker.Service + if err := json.Unmarshal([]byte(bc.ServicesJSON), &services); err != nil { + jsonError(w, http.StatusInternalServerError, "Failed to parse services") + return + } + + found := false + for i := range services { + if services[i].Host == host { + services[i].Enabled = req.Enabled + found = true + break + } + } + if !found { + jsonError(w, http.StatusNotFound, fmt.Sprintf("Service not found for host %q", host)) + return + } + + servicesJSON, err := json.Marshal(services) + if err != nil { + jsonError(w, http.StatusInternalServerError, "Failed to marshal services") + return + } + + if _, err := s.store.SetBrokerConfig(ctx, ns.ID, string(servicesJSON)); err != nil { + jsonError(w, http.StatusInternalServerError, "Failed to update services") + return + } + + jsonOK(w, map[string]interface{}{ + "vault": name, + "host": host, + "enabled": *req.Enabled, + }) +} + func (s *Server) handleServicesSet(w http.ResponseWriter, r *http.Request) { ctx := r.Context() name := r.PathValue("name") diff --git a/internal/server/server.go b/internal/server/server.go index a934dbf..7cd86d2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -617,6 +617,7 @@ func New(addr string, store Store, encKey []byte, notifier *notify.Notifier, ini mux.HandleFunc("GET /v1/vaults/{name}/services", s.requireInitialized(s.requireAuth(s.handleServicesGet))) mux.HandleFunc("POST /v1/vaults/{name}/services", s.requireInitialized(s.requireAuth(limitBody(s.handleServicesUpsert)))) mux.HandleFunc("PUT /v1/vaults/{name}/services", s.requireInitialized(s.requireAuth(limitBody(s.handleServicesSet)))) + mux.HandleFunc("PATCH /v1/vaults/{name}/services/{host}", s.requireInitialized(s.requireAuth(limitBody(s.handleServicePatch)))) mux.HandleFunc("DELETE /v1/vaults/{name}/services/{host}", s.requireInitialized(s.requireAuth(s.handleServiceRemove))) mux.HandleFunc("DELETE /v1/vaults/{name}/services", s.requireInitialized(s.requireAuth(s.handleServicesClear))) mux.HandleFunc("GET /v1/vaults/{name}/services/credential-usage", s.requireInitialized(s.requireAuth(s.handleServicesCredentialUsage))) diff --git a/web/src/components/ProposalPreview.tsx b/web/src/components/ProposalPreview.tsx index 29545cc..846b6a0 100644 --- a/web/src/components/ProposalPreview.tsx +++ b/web/src/components/ProposalPreview.tsx @@ -1,3 +1,5 @@ +import { StatusBadge } from "./shared"; + export interface Auth { type: string; token?: string; @@ -13,6 +15,7 @@ export interface Service { action: string; host: string; description?: string; + enabled?: boolean; auth?: Auth; } @@ -178,6 +181,14 @@ export default function ProposalPreview({ data }: { data: ProposalData }) {

{service.description}

)} + {service.enabled !== undefined && ( +
+
+ State +
+ +
+ )} {service.auth && } ))} diff --git a/web/src/components/Toggle.tsx b/web/src/components/Toggle.tsx new file mode 100644 index 0000000..e9aa9ea --- /dev/null +++ b/web/src/components/Toggle.tsx @@ -0,0 +1,33 @@ +interface ToggleProps { + checked: boolean; + onChange?: (next: boolean) => void; + disabled?: boolean; + ariaLabel?: string; +} + +export default function Toggle({ + checked, + onChange, + disabled = false, + ariaLabel, +}: ToggleProps) { + return ( + + ); +} diff --git a/web/src/components/shared.tsx b/web/src/components/shared.tsx index 344b337..b78ee66 100644 --- a/web/src/components/shared.tsx +++ b/web/src/components/shared.tsx @@ -6,6 +6,8 @@ export function StatusBadge({ status }: { status: string }) { expired: "bg-bg text-text-dim border-border", active: "bg-success-bg text-success border-success/20", revoked: "bg-danger-bg text-danger border-danger/20", + enabled: "bg-success-bg text-success border-success/20", + disabled: "bg-danger-bg text-danger border-danger/20", }; return ( diff --git a/web/src/pages/vault/ServicesTab.tsx b/web/src/pages/vault/ServicesTab.tsx index 67b597a..0003be4 100644 --- a/web/src/pages/vault/ServicesTab.tsx +++ b/web/src/pages/vault/ServicesTab.tsx @@ -10,15 +10,21 @@ import Modal from "../../components/Modal"; import Button from "../../components/Button"; import Input from "../../components/Input"; import FormField from "../../components/FormField"; +import Toggle from "../../components/Toggle"; import { type Auth, AUTH_TYPE_LABELS } from "../../components/ProposalPreview"; import { apiFetch } from "../../lib/api"; interface Service { host: string; description?: string; + enabled?: boolean; auth: Auth; } +function isEnabled(service: Service): boolean { + return service.enabled !== false; +} + const AUTH_TYPE_OPTIONS: { value: string; label: string }[] = [ { value: "bearer", label: "Bearer token" }, { value: "basic", label: "HTTP Basic Auth" }, @@ -79,6 +85,30 @@ export default function ServicesTab() { setServices(updatedServices); } + async function toggleEnabled(index: number, next: boolean) { + const service = services[index]; + if (!service) return; + const applyEnabled = (want: boolean) => (list: Service[]) => + list.map((s) => (s.host === service.host ? { ...s, enabled: want } : s)); + setServices(applyEnabled(next)); + try { + const resp = await apiFetch( + `/v1/vaults/${encodeURIComponent(vaultName)}/services/${encodeURIComponent(service.host)}`, + { + method: "PATCH", + body: JSON.stringify({ enabled: next }), + } + ); + if (!resp.ok) { + const data = await resp.json(); + throw new Error(data.error || "Failed to update service."); + } + } catch (err: unknown) { + setServices(applyEnabled(!next)); + setError(err instanceof Error ? err.message : "Failed to update service."); + } + } + async function handleDelete() { if (deleteIndex === null) return; setDeleting(true); @@ -123,6 +153,18 @@ export default function ServicesTab() { ); }, }, + { + key: "enabled", + header: "Enabled", + render: (service, index) => ( + toggleEnabled(index, next)} + ariaLabel={`Toggle ${service.host}`} + /> + ), + }, ...(isAdmin ? [ { @@ -253,6 +295,7 @@ function ServiceModal({ }) { const [host, setHost] = useState(initial?.host ?? ""); const [description, setDescription] = useState(initial?.description ?? ""); + const [enabled, setEnabled] = useState(initial ? initial.enabled !== false : true); const [authType, setAuthType] = useState(initial?.auth?.type ?? "bearer"); // Bearer fields @@ -333,6 +376,7 @@ function ServiceModal({ const service: Service = { host: host.trim(), ...(description.trim() && { description: description.trim() }), + ...(enabled ? {} : { enabled: false }), auth: buildAuth(), }; await onSave(service); @@ -380,6 +424,12 @@ function ServiceModal({ onChange={(e) => setDescription(e.target.value)} /> + + +