Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ With `-o json`, the same error is JSON on stderr:

### How to return one

- Return a `*util.CLIError` (`packages/util/cli_error.go`) from one of the constructors in `packages/cmd/secrets_manager_errors.go`, and print it with `exitWithError(cmd, err)`. That function picks text, JSON, or YAML from `-o`, and exits `1`.
- Return a `*util.CLIError` (`packages/util/cli_error.go`) from one of the constructors in `packages/cmd/secrets_manager_errors.go` (Agent Vault's are in `packages/cmd/agent_vault_errors.go`), and print it with `exitWithError(cmd, err)`. That function picks text, JSON, or YAML from `-o`, and exits `1`.
- Pass API errors through `classifyAPIError(err, scope, notFound)`. It uses only what is stable: the status code, the error names the backend guarantees (`TokenError`, `PermissionDenied`, `ProjectMembershipNotFound`), and, for a 404, the `notFound` error the caller passes when the request had one subject (an environment by slug, a project by ID). `scope` is the `errorScope` the command was working on. A 403 `ProjectMembershipNotFound` becomes `project_not_found` for the project `scope` names, so someone who isn't a member is told the same as for a project that doesn't exist, and the project's ID isn't echoed back. It never reads the message text, which the backend can reword at any time. Any other error comes back unchanged and prints as it always did, including the backend's own message.
- A backend error that can only be told apart by its message (a slug that's taken, delete protection, a plan limit) has no code until the backend gives it a stable error name. Never match on message text to add one.
- Build every value in a suggested command with `util.SuggestedValue` (or `util.SuggestedDomain` for `--domain`). Anything that isn't a UUID, slug, secret name, or folder path becomes a `<placeholder>`.
Expand All @@ -70,7 +70,7 @@ With `-o json`, the same error is JSON on stderr:
| `permission_denied` | The role doesn't allow the action (403) |
| `project_not_linked` | No `--projectId`, no `INFISICAL_PROJECT_ID`, and no `.infisical.yaml` |
| `project_config_invalid` | The directory's `.infisical.yaml` (or a legacy `.infisical.json`) exists but can't be read. Suggests `infisical init` |
| `project_not_found` | The project doesn't exist, or the caller isn't a member of it (404, or 403 `ProjectMembershipNotFound`) |
| `project_not_found` | The project doesn't exist, or the caller isn't a member of it (404, or 403 `ProjectMembershipNotFound`). For Agent Vault, also a project that isn't an Agent Vault one |
| `project_type_not_supported` | A project of another product was passed to a `secrets-manager` command |
| `environment_not_found` | The environment slug doesn't exist in the project (404) |
| `environment_exists` | `environments create` (or `update --slug`) with a slug an active environment has. Nothing is written |
Expand All @@ -80,7 +80,11 @@ With `-o json`, the same error is JSON on stderr:
| `token_project_mismatch` | `secrets create` or `update` with a service token for a different project than the one selected. Nothing is written |
| `invalid_argument` | An argument the command can't use, such as the same secret name given twice to `secrets create`, or a secret with no name or no value, or a name that starts with a number. Nothing is written |
| `project_delete_protected` | `projects delete` on a project with delete protection on, read from its settings. Nothing is deleted |
| `project_required` | A delete ran without `--projectId`. Deletes never use `INFISICAL_PROJECT_ID` or `.infisical.yaml` |
| `project_required` | A delete ran without `--projectId`. Deletes never use `INFISICAL_PROJECT_ID` or `.infisical.yaml`. For Agent Vault, no project from `--project-id`, `INFISICAL_AGENT_VAULT_PROJECT_ID` or `INFISICAL_PROJECT_ID` |
| `invalid_project_id` | An Agent Vault project ID from `--project-id` or a variable isn't a project ID |
| `access_bundle_required` | `agent-vault sessions create` without `--access-bundle` or `INFISICAL_AGENT_VAULT_ACCESS_BUNDLE` |
| `access_bundle_not_granted` | `agent-vault sessions create` with an access bundle the caller (or the user a session is on behalf of) can't use, or that doesn't exist (400 `AccessBundleNotGranted`) |
| `invalid_access_bundle` | An access bundle name from `--access-bundle` or `INFISICAL_AGENT_VAULT_ACCESS_BUNDLE` isn't a valid name. Nothing is sent |
| `secret_exists` | `secrets create` on a key that exists. Nothing is written |
| `secret_not_found` | `secrets update` on a key that doesn't exist. Nothing is written |
| `folder_not_found` | `folders get`, `update` or `delete` on a folder that doesn't exist at `--path` |
Expand Down
2 changes: 1 addition & 1 deletion packages/agentvault/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ func recentLocked(entries map[string]refusedEntry, key string) (refusedEntry, bo
return refusedEntry{}, false
}

var errSessionGone = errors.New("session is no longer valid")
var errSessionGone = errors.New("session is no longer valid, or it belongs to a different Agent Vault project than this proxy")

// The name the server puts on a 401 that is about this proxy's own token rather than the session it asked
// about. Without it the two are the same status and the same error class.
Expand Down
6 changes: 3 additions & 3 deletions packages/agentvault/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,8 @@ func classifyGateRefusal(err error) gateRefusal {
"agent-vault: refused, Infisical rejected this proxy's token"}
}
if isSessionGone(err) {
return gateRefusal{log.Warn(), decisionBlocked, http.StatusForbidden, "the session is no longer valid",
"agent-vault: refused, the session is no longer valid"}
return gateRefusal{log.Warn(), decisionBlocked, http.StatusForbidden, "the session is no longer valid, or it belongs to a different Agent Vault project than this proxy",
"agent-vault: refused, the session is no longer valid, or it belongs to a different Agent Vault project than this proxy"}
}
return gateRefusal{log.Error().Err(err), decisionError, http.StatusBadGateway, "failed to resolve the session",
"agent-vault: refused, the session could not be resolved"}
Expand Down Expand Up @@ -643,7 +643,7 @@ func (ps *proxyServer) forwardHTTP(w http.ResponseWriter, r *http.Request, t tun
case isProxyTokenRejected(err):
decision, status, body = decisionError, http.StatusServiceUnavailable, proxyRevokedBody
case isSessionGone(err):
decision, status, body = decisionBlocked, http.StatusForbidden, "the session is no longer valid"
decision, status, body = decisionBlocked, http.StatusForbidden, "the session is no longer valid, or it belongs to a different Agent Vault project than this proxy"
case errors.Is(err, errSessionResolve):
decision, status, body = decisionError, http.StatusBadGateway, "failed to resolve the session"
case err != nil:
Expand Down
2 changes: 1 addition & 1 deletion packages/agentvault/proxy_tunnel_errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func TestASessionLostInsideTheTunnelIsA403WithTheGateText(t *testing.T) {
if status != http.StatusForbidden {
t.Fatalf("a dead session inside the tunnel got %d, which SDKs retry; want 403", status)
}
if body != "the session is no longer valid" {
if body != "the session is no longer valid, or it belongs to a different Agent Vault project than this proxy" {
t.Fatalf("body %q differs from what the CONNECT gate says", body)
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/agentvault/socks5_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ func TestSocks5RefusesAtLogin(t *testing.T) {
// Only a session that is gone fails the login; a problem on the proxy's side fails the request.
wantAuth, wantRep byte
}{
{"revoked or expired session", &api.APIError{StatusCode: 401, Name: infisicalUnauthorizedName}, "the session is no longer valid", socks5AuthStatusRefus, 0},
{"revoked or expired session", &api.APIError{StatusCode: 401, Name: infisicalUnauthorizedName}, "the session is no longer valid, or it belongs to a different Agent Vault project than this proxy", socks5AuthStatusRefus, 0},
{"proxy token rejected", &api.APIError{StatusCode: 401, Name: proxyTokenRejectedName}, "Infisical rejected this proxy's token", socks5AuthStatusOK, socks5RepFailure},
{"Infisical unreachable", errors.New("dial tcp: connection refused"), "the session could not be resolved", socks5AuthStatusOK, socks5RepFailure},
{"Infisical rate limited", &api.APIError{StatusCode: 429}, "the session could not be resolved", socks5AuthStatusOK, socks5RepFailure},
Expand Down Expand Up @@ -351,7 +351,7 @@ func TestSocks5RefusalWarningsAreRateLimited(t *testing.T) {
if n := strings.Count(out, `"level":"warn","door":"socks5"`); n != 1 {
t.Fatalf("got %d warn lines for 4 refusals in a minute, want 1: %s", n, out)
}
if n := strings.Count(out, "the session is no longer valid"); n != 4 {
if n := strings.Count(out, "the session is no longer valid, or it belongs to a different Agent Vault project than this proxy"); n != 4 {
t.Fatalf("got %d refusal lines, want each one at warn or debug: %s", n, out)
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/agentvault/upstream_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ func TestARefreshThatEndsTheSessionIsAnsweredAsARevokedSession(t *testing.T) {
status int
body string
}{
{"session revoked", &api.APIError{StatusCode: http.StatusUnauthorized, Name: infisicalUnauthorizedName}, http.StatusForbidden, "the session is no longer valid"},
{"session revoked", &api.APIError{StatusCode: http.StatusUnauthorized, Name: infisicalUnauthorizedName}, http.StatusForbidden, "the session is no longer valid, or it belongs to a different Agent Vault project than this proxy"},
{"proxy token revoked", &api.APIError{StatusCode: http.StatusUnauthorized, Name: proxyTokenRejectedName}, http.StatusServiceUnavailable, proxyRevokedBody},
}
for _, tc := range cases {
Expand Down
55 changes: 50 additions & 5 deletions packages/api/agent_vault.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,15 +240,25 @@ func CallCreateAgentVaultSessionLogChunk(ctx context.Context, httpClient *resty.
}

type CreateAgentVaultSessionRequest struct {
ProjectID string `json:"projectId,omitempty"`
AccessBundle string `json:"accessBundle"`
TTL string `json:"ttl"`
Label string `json:"label,omitempty"`
}

type AgentVaultSessionAccessBundle struct {
ID string `json:"id"`
Name string `json:"name"`
}

type AgentVaultSession struct {
ID string `json:"id"`
Token string `json:"token"`
ExpiresAt *string `json:"expiresAt"`
ID string `json:"id"`
Token string `json:"token"`
ProjectID string `json:"projectId"`
Label *string `json:"label"`
AccessBundle *AgentVaultSessionAccessBundle `json:"accessBundle"`
ExpiresAt *string `json:"expiresAt"`
CreatedAt string `json:"createdAt"`
}

type CreateAgentVaultSessionResponse struct {
Expand Down Expand Up @@ -289,6 +299,33 @@ func CallRevokeAgentVaultSession(ctx context.Context, httpClient *resty.Client,
return nil
}

type RevokeAgentVaultSessionByTokenResponse struct {
Session struct {
ID string `json:"id"`
RevokedAt *string `json:"revokedAt"`
} `json:"session"`
}

// The session token is the only credential: whoever holds it may end the session it names.
func CallRevokeAgentVaultSessionByToken(ctx context.Context, httpClient *resty.Client, sessionToken string) (RevokeAgentVaultSessionByTokenResponse, error) {
var res RevokeAgentVaultSessionByTokenResponse
response, err := httpClient.
R().
SetContext(ctx).
SetResult(&res).
SetHeader("User-Agent", USER_AGENT).
SetAuthToken(sessionToken).
Post(fmt.Sprintf("%v/v1/agent-vault/session/revoke", config.INFISICAL_URL))

if err != nil {
return RevokeAgentVaultSessionByTokenResponse{}, NewGenericRequestError("CallRevokeAgentVaultSessionByToken", err)
}
if response.IsError() {
return RevokeAgentVaultSessionByTokenResponse{}, NewAPIErrorWithResponse("CallRevokeAgentVaultSessionByToken", response, nil)
}
return res, nil
}

type AgentVaultSkillFile struct {
Path string `json:"path"`
Mode string `json:"mode"`
Expand Down Expand Up @@ -353,12 +390,13 @@ type ListAgentVaultSkillsResponse struct {
TotalCount int `json:"totalCount"`
}

func CallListAgentVaultSkillsByName(httpClient *resty.Client, name string) (ListAgentVaultSkillsResponse, error) {
func CallListAgentVaultSkillsByName(httpClient *resty.Client, projectID, name string) (ListAgentVaultSkillsResponse, error) {
var res ListAgentVaultSkillsResponse
response, err := httpClient.
R().
SetResult(&res).
SetHeader("User-Agent", USER_AGENT).
SetQueryParam("projectId", projectID).
SetQueryParam("name", name).
Get(fmt.Sprintf("%v/v1/agent-vault/skills", config.INFISICAL_URL))

Expand All @@ -375,12 +413,19 @@ type AgentVaultSkillFilesRequest struct {
Files []AgentVaultSkillFile `json:"files"`
}

// Separate from AgentVaultSkillFilesRequest because a publish names its skill by ID and the server takes the
// project from that skill.
type CreateAgentVaultSkillRequest struct {
ProjectID string `json:"projectId"`
Files []AgentVaultSkillFile `json:"files"`
}

type CreateAgentVaultSkillResponse struct {
Skill AgentVaultSkill `json:"skill"`
Version AgentVaultSkillVersion `json:"version"`
}

func CallCreateAgentVaultSkill(httpClient *resty.Client, request AgentVaultSkillFilesRequest) (CreateAgentVaultSkillResponse, error) {
func CallCreateAgentVaultSkill(httpClient *resty.Client, request CreateAgentVaultSkillRequest) (CreateAgentVaultSkillResponse, error) {
var res CreateAgentVaultSkillResponse
response, err := httpClient.
R().
Expand Down
42 changes: 42 additions & 0 deletions packages/api/agent_vault_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
Expand Down Expand Up @@ -74,3 +75,44 @@ func TestCreateAgentVaultSessionRequestSendsALabelOnlyWhenGiven(t *testing.T) {
t.Errorf("want the label in the body, got %s", labeled)
}
}

func TestCreateAgentVaultSessionRequestSendsTheProject(t *testing.T) {
body, err := json.Marshal(CreateAgentVaultSessionRequest{ProjectID: "p1", AccessBundle: "coding-agent", TTL: "never"})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), `"projectId":"p1"`) {
t.Errorf("want the project in the body, got %s", body)
}
}

func TestOnlyASkillCreateSendsTheProject(t *testing.T) {
created, _ := json.Marshal(CreateAgentVaultSkillRequest{ProjectID: "p1"})
published, _ := json.Marshal(AgentVaultSkillFilesRequest{})
if !strings.Contains(string(created), `"projectId":"p1"`) || strings.Contains(string(published), "projectId") {
t.Fatalf("create %s, publish %s", created, published)
}
}

func TestRevokeAgentVaultSessionByTokenUsesNoOtherCredential(t *testing.T) {
var auth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"statusCode":401,"message":"Invalid session token"}`))
}))
defer srv.Close()
old := config.INFISICAL_URL
config.INFISICAL_URL = srv.URL + "/api"
t.Cleanup(func() { config.INFISICAL_URL = old })

_, err := CallRevokeAgentVaultSessionByToken(context.Background(), resty.New(), "agv_x")
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusUnauthorized {
t.Fatalf("err %v, want a 401", err)
}
if auth != "Bearer agv_x" {
t.Fatalf("Authorization %q", auth)
}
}
Loading
Loading