diff --git a/CLI.md b/CLI.md index 4bd53e6e..8e366efe 100644 --- a/CLI.md +++ b/CLI.md @@ -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 ``. @@ -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 | @@ -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` | diff --git a/packages/agentvault/cache.go b/packages/agentvault/cache.go index 3ee8efb6..1beb17f9 100644 --- a/packages/agentvault/cache.go +++ b/packages/agentvault/cache.go @@ -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. diff --git a/packages/agentvault/proxy.go b/packages/agentvault/proxy.go index 9ed20109..f7904fdb 100644 --- a/packages/agentvault/proxy.go +++ b/packages/agentvault/proxy.go @@ -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"} @@ -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: diff --git a/packages/agentvault/proxy_tunnel_errors_test.go b/packages/agentvault/proxy_tunnel_errors_test.go index 6a0742a9..82bcd77c 100644 --- a/packages/agentvault/proxy_tunnel_errors_test.go +++ b/packages/agentvault/proxy_tunnel_errors_test.go @@ -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) } } diff --git a/packages/agentvault/socks5_e2e_test.go b/packages/agentvault/socks5_e2e_test.go index 03163b7d..6b6244a4 100644 --- a/packages/agentvault/socks5_e2e_test.go +++ b/packages/agentvault/socks5_e2e_test.go @@ -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}, @@ -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) } } diff --git a/packages/agentvault/upstream_auth_test.go b/packages/agentvault/upstream_auth_test.go index 8bad7971..d3c36b5e 100644 --- a/packages/agentvault/upstream_auth_test.go +++ b/packages/agentvault/upstream_auth_test.go @@ -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 { diff --git a/packages/api/agent_vault.go b/packages/api/agent_vault.go index d2e4239f..5d4c5d72 100644 --- a/packages/api/agent_vault.go +++ b/packages/api/agent_vault.go @@ -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 { @@ -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"` @@ -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)) @@ -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(). diff --git a/packages/api/agent_vault_test.go b/packages/api/agent_vault_test.go index b7d0045e..e6dd13a3 100644 --- a/packages/api/agent_vault_test.go +++ b/packages/api/agent_vault_test.go @@ -3,6 +3,7 @@ package api import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" @@ -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) + } +} diff --git a/packages/cmd/agent_vault_errors.go b/packages/cmd/agent_vault_errors.go new file mode 100644 index 00000000..af066199 --- /dev/null +++ b/packages/cmd/agent_vault_errors.go @@ -0,0 +1,111 @@ +package cmd + +import ( + "errors" + "fmt" + "net/http" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/util" +) + +const ( + errCodeAccessBundleRequired = "access_bundle_required" + errCodeAccessBundleNotGranted = "access_bundle_not_granted" + errCodeInvalidAccessBundle = "invalid_access_bundle" + errCodeInvalidProjectID = "invalid_project_id" +) + +func errAgentVaultInvalidOutput(format string) *util.CLIError { + return &util.CLIError{Code: errCodeInvalidFlagValue, + Message: fmt.Sprintf("--output must be json or yaml, not %s: leave it out to print only the token.", quoted(format, "format"))} +} + +func errAgentVaultAccessBundleRequired() *util.CLIError { + return &util.CLIError{Code: errCodeAccessBundleRequired, + Message: fmt.Sprintf("An access bundle is required: pass --access-bundle or set %s.", agentVaultAccessBundleEnv)} +} + +func errAgentVaultAccessBundleNotGranted(bundle string, cause error) *util.CLIError { + return &util.CLIError{Code: errCodeAccessBundleNotGranted, Cause: cause, + Message: fmt.Sprintf("Access bundle %s isn't granted to you in this Agent Vault project, or doesn't exist. Check the name, or ask an Agent Vault admin to grant it.", quoted(bundle, "access bundle"))} +} + +func errAgentVaultEmptyAccessBundle() *util.CLIError { + return &util.CLIError{Code: errCodeInvalidFlagValue, + Message: `--access-bundle was given but names no bundle: pass a name like "coding-agent".`} +} + +func errAgentVaultOneAccessBundle() *util.CLIError { + return &util.CLIError{Code: errCodeInvalidFlagValue, + Message: "A session carries one access bundle: pass --access-bundle once."} +} + +func errAgentVaultInvalidAccessBundle(name string, source agentVaultSource) *util.CLIError { + return &util.CLIError{Code: errCodeInvalidAccessBundle, + Message: fmt.Sprintf(`%s from %s is not an access bundle name: names are lowercase letters, numbers and hyphens, for example "coding-agent".`, + quoted(name, "access bundle"), agentVaultSourceName(source, "--access-bundle", agentVaultAccessBundleEnv))} +} + +// An empty --project-id is usually "$PROJECT" with the variable unset, so it never falls through to a +// project the operator did not name. +func errAgentVaultEmptyProjectID() *util.CLIError { + return &util.CLIError{Code: errCodeInvalidFlagValue, + Message: fmt.Sprintf("--project-id was given but is empty: pass the Agent Vault project's ID, or drop the flag and set %s.", agentVaultProjectIDEnv)} +} + +func errAgentVaultProjectRequired() *util.CLIError { + return &util.CLIError{Code: errCodeProjectRequired, + Message: fmt.Sprintf("No Agent Vault project is set: pass --project-id, or set %s or %s.", agentVaultProjectIDEnv, util.INFISICAL_PROJECT_ID_NAME)} +} + +func errAgentVaultInvalidProjectID(value, source string) *util.CLIError { + return &util.CLIError{Code: errCodeInvalidProjectID, + Message: fmt.Sprintf("%s from %s is not a project ID: copy the ID from the Agent Vault project's settings in Infisical.", quoted(value, "project ID"), source)} +} + +// INFISICAL_PROJECT_ID is often already set for Secrets Management, and the server answers 404 for a project +// that is not an Agent Vault one, so a project from there gets the hint to use the Agent Vault variable. +func errAgentVaultProjectNotFound(project agentVaultProject, cause error) *util.CLIError { + message := fmt.Sprintf("Agent Vault project %s from %s doesn't exist, or you don't have access to it.", quoted(project.ID, "project ID"), project.Source) + if project.Source == util.INFISICAL_PROJECT_ID_NAME { + message += fmt.Sprintf(" %s may name a Secrets Management project: set %s or pass --project-id with the Agent Vault project's ID.", + util.INFISICAL_PROJECT_ID_NAME, agentVaultProjectIDEnv) + } else { + message += " Copy the ID from the Agent Vault project's settings in Infisical." + } + return &util.CLIError{Code: errCodeProjectNotFound, Cause: cause, Message: message} +} + +// A non-member is told the same as for a project that doesn't exist, without the ID, so the error can't be used to +// confirm one. +func errAgentVaultProjectNotAMember(project agentVaultProject, cause error) *util.CLIError { + return &util.CLIError{ + Code: errCodeProjectNotFound, + Cause: cause, + Message: fmt.Sprintf("The Agent Vault project from %s doesn't exist, or you don't have access to it.", project.Source), + } +} + +// classifyAgentVaultAPIError is classifyAPIError for a request scoped to one Agent Vault project. A 404 from a +// route the backend doesn't have yet is left alone, so an older Infisical still prints its own answer. accessBundle +// is the bundle the request named, or empty when it named none. +func classifyAgentVaultAPIError(err error, project agentVaultProject, accessBundle string) error { + var apiErr *api.APIError + if !errors.As(err, &apiErr) { + return err + } + if apiErr.StatusCode == http.StatusForbidden && apiErr.Name == "ProjectMembershipNotFound" { + return errAgentVaultProjectNotAMember(project, err) + } + if apiErr.StatusCode == http.StatusBadRequest && apiErr.Name == "AccessBundleNotGranted" && accessBundle != "" { + return errAgentVaultAccessBundleNotGranted(accessBundle, err) + } + var notFound func(cause error) *util.CLIError + // The route-missing answer is named "Not Found" and the app's own 404s "NotFound", so the name tells them apart + // without reading the message. + if !(apiErr.StatusCode == http.StatusNotFound && apiErr.Name == infisicalRouteMissingName) { + notFound = func(cause error) *util.CLIError { return errAgentVaultProjectNotFound(project, cause) } + } + return classifyAPIError(err, errorScope{ProjectID: project.ID}, notFound) +} diff --git a/packages/cmd/agent_vault_errors_test.go b/packages/cmd/agent_vault_errors_test.go new file mode 100644 index 00000000..4208bcbb --- /dev/null +++ b/packages/cmd/agent_vault_errors_test.go @@ -0,0 +1,231 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Infisical/infisical-merge/packages/config" + "github.com/Infisical/infisical-merge/packages/telemetry" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +func agentVaultConstructedErrors() []*util.CLIError { + cause := errors.New("cause") + return []*util.CLIError{ + errAgentVaultInvalidOutput("xml"), + errAgentVaultAccessBundleRequired(), + errAgentVaultEmptyAccessBundle(), + errAgentVaultOneAccessBundle(), + errAgentVaultInvalidAccessBundle("Coding Agent", agentVaultSourceFlag), + errAgentVaultAccessBundleNotGranted("coding-agent", cause), + errAgentVaultEmptyProjectID(), + errAgentVaultProjectRequired(), + errAgentVaultInvalidProjectID("my-project", "--project-id"), + errAgentVaultProjectNotFound(agentVaultProject{ID: projectFromFlag, Source: "--project-id"}, cause), + errAgentVaultProjectNotFound(agentVaultProject{ID: projectFromProjEnv, Source: util.INFISICAL_PROJECT_ID_NAME}, cause), + } +} + +// runAgentVaultSessionsCreateForTest runs `infisical agent-vault sessions create ` on a fresh command, with +// an identity token from the environment, against an API that answers every request with status and the error +// name apiName. setup runs after the environment is reset. It returns stderr, whether the command exited 1, and +// how many requests reached the API. +func runAgentVaultSessionsCreateForTest(t *testing.T, setup func(), status int, apiName string, args ...string) (stderr string, exited bool, requests int) { + t.Helper() + isolateAgentVaultProject(t) + t.Setenv(agentVaultAccessBundleEnv, "") + t.Setenv(util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME, "") + t.Setenv(util.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME, "") + t.Setenv(util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME, "") + t.Setenv(util.INFISICAL_TOKEN_NAME, "identity-access-token") + if setup != nil { + setup() + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = fmt.Fprintf(w, `{"statusCode":%d,"error":%q,"message":"anything","reqId":"req-1"}`, status, apiName) + })) + t.Cleanup(srv.Close) + oldURL := config.INFISICAL_URL + config.INFISICAL_URL = srv.URL + "/api" + + errOut := captureStderr(t) + oldExit, oldTelemetry := exitProcess, Telemetry + exitProcess = func(int) { panic(exitCalled{}) } + Telemetry = telemetry.NewTelemetry(false) + t.Cleanup(func() { config.INFISICAL_URL, exitProcess, Telemetry = oldURL, oldExit, oldTelemetry }) + + cmd := &cobra.Command{Use: "create", Run: runAgentVaultSessionsCreate} + cmd.Flags().Bool("silent", false, "") + cmd.Flags().String("project-id", "", "") + cmd.Flags().StringArray("access-bundle", nil, "") + cmd.Flags().String("ttl", "never", "") + cmd.Flags().String("label", "", "") + cmd.Flags().String("client-id", "", "") + cmd.Flags().String("client-secret", "", "") + cmd.Flags().StringP("output", "o", "", "") + if err := cmd.ParseFlags(args); err != nil { + t.Fatal(err) + } + + func() { + defer func() { + if r := recover(); r != nil { + if _, ok := r.(exitCalled); !ok { + panic(r) + } + exited = true + } + }() + cmd.Run(cmd, cmd.Flags().Args()) + }() + return errOut.String(), exited, requests +} + +type cliErrorFields struct { + Code string `json:"code" yaml:"code"` + Message string `json:"message" yaml:"message"` + Next []string `json:"next" yaml:"next"` +} + +func decodeCLIError(t *testing.T, format, stderr string) cliErrorFields { + t.Helper() + var body struct { + Error cliErrorFields `json:"error" yaml:"error"` + } + var err error + if format == util.OUTPUT_FORMAT_JSON { + err = json.Unmarshal([]byte(stderr), &body) + } else { + err = yaml.Unmarshal([]byte(stderr), &body) + } + if err != nil || body.Error.Code == "" { + t.Fatalf("stderr is not a %s error (%v):\n%s", format, err, stderr) + } + return body.Error +} + +func TestAgentVaultSessionsCreateErrorsAreStructured(t *testing.T) { + withProject := func(args ...string) []string { return append(args, "--project-id", projectFromFlag) } + cases := []struct { + name string + args []string + setup func() + status int + apiName string + code string + message string + }{ + {name: "no access bundle", args: withProject(), code: errCodeAccessBundleRequired, message: agentVaultAccessBundleEnv}, + {name: "empty access bundle", args: withProject("--access-bundle", " "), code: errCodeInvalidFlagValue, message: "names no bundle"}, + {name: "two access bundles", args: withProject("--access-bundle", "a", "--access-bundle", "b"), code: errCodeInvalidFlagValue, message: "one access bundle"}, + {name: "invalid access bundle", args: withProject("--access-bundle", "Coding_Agent"), code: errCodeInvalidAccessBundle, message: `"Coding_Agent" from --access-bundle`}, + {name: "invalid access bundle from the environment", args: withProject(), + setup: func() { t.Setenv(agentVaultAccessBundleEnv, "Coding_Agent") }, code: errCodeInvalidAccessBundle, message: "from " + agentVaultAccessBundleEnv}, + {name: "no project", args: []string{"--access-bundle", "coding-agent"}, code: errCodeProjectRequired, message: agentVaultProjectIDEnv}, + {name: "empty project", args: []string{"--access-bundle", "coding-agent", "--project-id", " "}, code: errCodeInvalidFlagValue, message: "--project-id was given but is empty"}, + {name: "not a project ID", args: []string{"--access-bundle", "coding-agent", "--project-id", "my-project"}, code: errCodeInvalidProjectID, message: `"my-project" from --project-id`}, + {name: "project 404", args: withProject("--access-bundle", "coding-agent"), status: 404, apiName: "NotFound", code: errCodeProjectNotFound, message: projectFromFlag}, + {name: "project 404 from INFISICAL_PROJECT_ID", args: []string{"--access-bundle", "coding-agent"}, + setup: func() { t.Setenv(util.INFISICAL_PROJECT_ID_NAME, projectFromProjEnv) }, + status: 404, apiName: "NotFound", code: errCodeProjectNotFound, message: "may name a Secrets Management project"}, + {name: "not a member", args: withProject("--access-bundle", "coding-agent"), status: 403, apiName: "ProjectMembershipNotFound", code: errCodeProjectNotFound, message: "from --project-id doesn't exist"}, + {name: "access bundle not granted", args: withProject("--access-bundle", "coding-agent"), status: 400, apiName: "AccessBundleNotGranted", + code: errCodeAccessBundleNotGranted, message: `Access bundle "coding-agent" isn't granted to you`}, + {name: "permission denied", args: withProject("--access-bundle", "coding-agent"), status: 403, apiName: "PermissionDenied", code: errCodePermissionDenied}, + {name: "token rejected", args: withProject("--access-bundle", "coding-agent"), status: 401, apiName: "UnauthorizedError", code: errCodeAuthRequired}, + } + for _, format := range []string{util.OUTPUT_FORMAT_JSON, util.OUTPUT_FORMAT_YAML} { + for _, c := range cases { + t.Run(format+"/"+c.name, func(t *testing.T) { + status := c.status + if status == 0 { + status = http.StatusInternalServerError + } + stderr, exited, requests := runAgentVaultSessionsCreateForTest(t, c.setup, status, c.apiName, append(c.args, "-o", format)...) + if !exited { + t.Fatalf("want an exit, got stderr=%s", stderr) + } + got := decodeCLIError(t, format, stderr) + if got.Code != c.code || !strings.Contains(got.Message, c.message) { + t.Fatalf("want %s containing %q, got %s %q", c.code, c.message, got.Code, got.Message) + } + if c.status == 0 && requests != 0 { + t.Fatalf("a refused input should send nothing, sent %d requests", requests) + } + }) + } + } +} + +// An unknown -o value is the error being reported, so it can't pick the format: the error is text, and no +// session is minted whose token would go unprinted. +func TestAgentVaultSessionsCreateRefusesAnUnknownOutputAsText(t *testing.T) { + stderr, exited, requests := runAgentVaultSessionsCreateForTest(t, nil, http.StatusInternalServerError, "", + "--access-bundle", "coding-agent", "--project-id", projectFromFlag, "-o", "xml") + if !exited || requests != 0 { + t.Fatalf("exited=%v requests=%d", exited, requests) + } + if want := `--output must be json or yaml, not "xml": leave it out to print only the token.` + "\n"; stderr != want { + t.Fatalf("got %q, want %q", stderr, want) + } +} + +func TestAgentVaultSessionsCreateTextErrorsKeepTheirMessage(t *testing.T) { + stderr, exited, _ := runAgentVaultSessionsCreateForTest(t, nil, http.StatusInternalServerError, "", "--project-id", projectFromFlag) + if want := "An access bundle is required: pass --access-bundle or set " + agentVaultAccessBundleEnv + ".\n"; !exited || stderr != want { + t.Fatalf("got %q, want %q", stderr, want) + } +} + +// A token pasted into --access-bundle or --project-id never reaches the error, in any format. +func TestAgentVaultSessionsCreateErrorsNeverRepeatAPastedToken(t *testing.T) { + const token = "agv_Xq3vR8kLmN2pQ7sT1uW4yZ6aB9cD0eF5gH-jK_oPqRs" + for _, args := range [][]string{ + {"--access-bundle", token, "--project-id", projectFromFlag}, + {"--access-bundle", "coding-agent", "--project-id", token}, + } { + for _, format := range []string{"", util.OUTPUT_FORMAT_JSON, util.OUTPUT_FORMAT_YAML} { + withFormat := args + if format != "" { + withFormat = append(append([]string{}, args...), "-o", format) + } + stderr, exited, _ := runAgentVaultSessionsCreateForTest(t, nil, http.StatusInternalServerError, "", withFormat...) + if !exited || strings.Contains(stderr, token) || strings.Contains(stderr, "Xq3vR8") { + t.Errorf("%v -o %s: got %q", args, format, stderr) + } + } + } +} + +// An older Infisical without the route answers 404 for it, which says nothing about the project. +func TestClassifyAgentVaultAPIErrorLeavesAMissingRouteAlone(t *testing.T) { + project := agentVaultProject{ID: projectFromFlag, Source: "--project-id"} + missingRoute := apiError(404, infisicalRouteMissingName, "Route POST:/api/v1/agent-vault/sessions not found") + if got := classifyAgentVaultAPIError(missingRoute, project, "x"); got != missingRoute { + t.Errorf("a missing route should print as before, got %v", got) + } + if got := classifyAgentVaultAPIError(apiError(404, infisicalRouteMissingName, "reworded"), project, "x"); got == nil || codeOf(got) != "" { + t.Errorf("a missing route is told apart by its name, not its message, got %v", got) + } + if got := classifyAgentVaultAPIError(apiError(403, "ProjectMembershipNotFound", ""), project, "x"); strings.Contains(got.Error(), projectFromFlag) { + t.Errorf("a non-member's error should not repeat the project ID, got %q", got.Error()) + } + if got := classifyAgentVaultAPIError(apiError(400, "BadRequest", "No access bundle named 'x'"), project, "x"); codeOf(got) != "" { + t.Errorf("a 400 without a stable name is told apart only by its message, so it keeps no code, got %v", got) + } + notGranted := classifyAgentVaultAPIError(apiError(400, "AccessBundleNotGranted", "No access bundle named 'x' is granted"), project, "x") + if codeOf(notGranted) != errCodeAccessBundleNotGranted || strings.Contains(notGranted.Error(), "is granted") { + t.Errorf("a 400 AccessBundleNotGranted should get its code and keep the server's message out, got %v", notGranted) + } +} diff --git a/packages/cmd/agent_vault_project.go b/packages/cmd/agent_vault_project.go new file mode 100644 index 00000000..58a2c523 --- /dev/null +++ b/packages/cmd/agent_vault_project.go @@ -0,0 +1,119 @@ +package cmd + +import ( + "errors" + "fmt" + "net/http" + "os" + "strings" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/google/uuid" + "github.com/spf13/cobra" +) + +const agentVaultProjectIDEnv = "INFISICAL_AGENT_VAULT_PROJECT_ID" + +type agentVaultProject struct { + ID string + // The flag or the variable the ID came from, as messages name it. + Source string + FromFlag bool +} + +// resolveAgentVaultProject picks the Agent Vault project from --project-id, then INFISICAL_AGENT_VAULT_PROJECT_ID, +// then INFISICAL_PROJECT_ID. The last is shared with Secrets Management, so it comes after the variable that can +// only name an Agent Vault project. +func resolveAgentVaultProject(cmd *cobra.Command) (agentVaultProject, error) { + if cmd.Flags().Changed("project-id") { + value, err := cmd.Flags().GetString("project-id") + if err != nil { + return agentVaultProject{}, fmt.Errorf("unable to read --project-id: %w", err) + } + if strings.TrimSpace(value) == "" { + return agentVaultProject{}, errAgentVaultEmptyProjectID() + } + project, err := agentVaultProjectFrom(value, "--project-id") + project.FromFlag = true + return project, err + } + + project, found, err := resolveAgentVaultProjectFromEnvironment() + if err != nil { + return agentVaultProject{}, err + } + if !found { + return agentVaultProject{}, errAgentVaultProjectRequired() + } + return project, nil +} + +func resolveAgentVaultProjectFromEnvironment() (project agentVaultProject, found bool, err error) { + if value := strings.TrimSpace(os.Getenv(agentVaultProjectIDEnv)); value != "" { + project, err = agentVaultProjectFrom(value, agentVaultProjectIDEnv) + return project, true, err + } + + if value := strings.TrimSpace(os.Getenv(util.INFISICAL_PROJECT_ID_NAME)); value != "" { + project, err = agentVaultProjectFrom(value, util.INFISICAL_PROJECT_ID_NAME) + return project, true, err + } + return agentVaultProject{}, false, nil +} + +func agentVaultProjectFrom(value, source string) (agentVaultProject, error) { + value = strings.TrimSpace(value) + // uuid.Parse also takes the braced and urn: spellings, which the server would refuse. + if _, err := uuid.Parse(value); err != nil || len(value) != 36 { + return agentVaultProject{}, errAgentVaultInvalidProjectID(value, source) + } + return agentVaultProject{ID: strings.ToLower(value), Source: source}, nil +} + +// A flag was typed for this run; anything else is ambient, so the command says which project it picked up. +func echoAgentVaultProject(cmd *cobra.Command, project agentVaultProject) { + if silent, _ := cmd.Flags().GetBool("silent"); silent || isStructuredOutputRequested(cmd) { + return + } + if !project.FromFlag { + util.PrintlnStderr(fmt.Sprintf("Agent Vault project %s from %s", project.ID, project.Source)) + } +} + +// INFISICAL_PROJECT_ID is often already set for Secrets Management, and the server answers 404 for a project +// that is not an Agent Vault one. +func agentVaultProjectHints(err error, project agentVaultProject) []string { + var apiErr *api.APIError + if project.Source != util.INFISICAL_PROJECT_ID_NAME || !errors.As(err, &apiErr) || + apiErr.StatusCode != http.StatusNotFound || apiErr.DocumentationURL != "" { + return nil + } + return []string{fmt.Sprintf( + "The project came from %s, which may name a Secrets Management project. Set %s or pass --project-id with the Agent Vault project's ID.", + util.INFISICAL_PROJECT_ID_NAME, agentVaultProjectIDEnv)} +} + +func agentVaultProjectSourceKind(project agentVaultProject) string { + switch project.Source { + case "--project-id": + return "flag" + case agentVaultProjectIDEnv: + return "agent-vault-env" + case util.INFISICAL_PROJECT_ID_NAME: + return "project-env" + default: + return "unknown" + } +} + +func printAgentVaultProjectForProfile() { + project, found, err := resolveAgentVaultProjectFromEnvironment() + switch { + case err != nil: + util.PrintlnStdout("Agent Vault project:", err.Error()) + case found: + util.PrintlnStdout("Agent Vault project:", project.ID) + util.PrintlnStdout("Agent Vault project via:", project.Source) + } +} diff --git a/packages/cmd/agent_vault_project_test.go b/packages/cmd/agent_vault_project_test.go new file mode 100644 index 00000000..974c6a5f --- /dev/null +++ b/packages/cmd/agent_vault_project_test.go @@ -0,0 +1,241 @@ +package cmd + +import ( + "bytes" + "io" + "strings" + "testing" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/spf13/cobra" +) + +const ( + projectFromFlag = "11111111-1111-4111-8111-111111111111" + projectFromAgvEnv = "22222222-2222-4222-8222-222222222222" + projectFromProjEnv = "44444444-4444-4444-8444-444444444444" +) + +func projectIDCmd(t *testing.T, args ...string) *cobra.Command { + t.Helper() + cmd := &cobra.Command{Use: "create", Run: func(*cobra.Command, []string) {}} + cmd.Flags().String("project-id", "", "") + if err := cmd.Flags().Parse(args); err != nil { + t.Fatal(err) + } + return cmd +} + +func isolateAgentVaultProject(t *testing.T) { + t.Helper() + t.Setenv(agentVaultProjectIDEnv, "") + t.Setenv(util.INFISICAL_PROJECT_ID_NAME, "") +} + +func TestResolveAgentVaultProjectOrder(t *testing.T) { + isolateAgentVaultProject(t) + t.Setenv(agentVaultProjectIDEnv, projectFromAgvEnv) + t.Setenv(util.INFISICAL_PROJECT_ID_NAME, projectFromProjEnv) + + steps := []struct { + args []string + wantID string + wantSource string + drop func() + }{ + {[]string{"--project-id", projectFromFlag}, projectFromFlag, "--project-id", nil}, + {nil, projectFromAgvEnv, agentVaultProjectIDEnv, func() { t.Setenv(agentVaultProjectIDEnv, "") }}, + {nil, projectFromProjEnv, util.INFISICAL_PROJECT_ID_NAME, nil}, + } + for _, step := range steps { + project, err := resolveAgentVaultProject(projectIDCmd(t, step.args...)) + if err != nil { + t.Fatal(err) + } + if project.ID != step.wantID || project.Source != step.wantSource { + t.Fatalf("got %s from %s, want %s from %s", project.ID, project.Source, step.wantID, step.wantSource) + } + if project.FromFlag != (step.wantSource == "--project-id") { + t.Fatalf("FromFlag = %v for %s", project.FromFlag, project.Source) + } + if step.drop != nil { + step.drop() + } + } +} + +func TestResolveAgentVaultProjectLowercasesTheID(t *testing.T) { + isolateAgentVaultProject(t) + + const upper = "ABCDEF12-AAAA-4AAA-8AAA-AAAAAAAAAAAA" + want := strings.ToLower(upper) + sources := []struct { + name string + set func() *cobra.Command + }{ + {"--project-id", func() *cobra.Command { return projectIDCmd(t, "--project-id", upper) }}, + {agentVaultProjectIDEnv, func() *cobra.Command { t.Setenv(agentVaultProjectIDEnv, upper); return projectIDCmd(t) }}, + {util.INFISICAL_PROJECT_ID_NAME, func() *cobra.Command { + t.Setenv(agentVaultProjectIDEnv, "") + t.Setenv(util.INFISICAL_PROJECT_ID_NAME, upper) + return projectIDCmd(t) + }}, + } + for _, source := range sources { + project, err := resolveAgentVaultProject(source.set()) + if err != nil { + t.Fatalf("%s: %v", source.name, err) + } + if project.Source != source.name || project.ID != want { + t.Fatalf("%s: got %s from %s, want %s", source.name, project.ID, project.Source, want) + } + } +} + +func TestResolveAgentVaultProjectRefusesAnEmptyFlag(t *testing.T) { + isolateAgentVaultProject(t) + t.Setenv(agentVaultProjectIDEnv, projectFromAgvEnv) + + _, err := resolveAgentVaultProject(projectIDCmd(t, "--project-id", " ")) + if err == nil || !strings.Contains(err.Error(), "--project-id was given but is empty") { + t.Fatalf("an explicitly empty --project-id should be an error, not a fall back to the environment: %v", err) + } +} + +func TestResolveAgentVaultProjectRefusesANonUuidNamingItsSource(t *testing.T) { + isolateAgentVaultProject(t) + + if _, err := resolveAgentVaultProject(projectIDCmd(t, "--project-id", "my-project")); err == nil || !strings.Contains(err.Error(), `"my-project" from --project-id`) { + t.Fatalf("flag: %v", err) + } + + t.Setenv(util.INFISICAL_PROJECT_ID_NAME, "{"+projectFromProjEnv+"}") + if _, err := resolveAgentVaultProject(projectIDCmd(t)); err == nil || !strings.Contains(err.Error(), "from "+util.INFISICAL_PROJECT_ID_NAME) { + t.Fatalf("braced uuid from %s: %v", util.INFISICAL_PROJECT_ID_NAME, err) + } + + t.Setenv(agentVaultProjectIDEnv, "my-agent-vault") + if _, err := resolveAgentVaultProject(projectIDCmd(t)); err == nil || !strings.Contains(err.Error(), `"my-agent-vault" from `+agentVaultProjectIDEnv) { + t.Fatalf("%s: %v", agentVaultProjectIDEnv, err) + } +} + +func TestResolveAgentVaultProjectWithNothingSetNamesEverySource(t *testing.T) { + isolateAgentVaultProject(t) + + _, err := resolveAgentVaultProject(projectIDCmd(t)) + if err == nil { + t.Fatal("want an error when no project is set") + } + for _, source := range []string{"--project-id", agentVaultProjectIDEnv, util.INFISICAL_PROJECT_ID_NAME} { + if !strings.Contains(err.Error(), source) { + t.Errorf("the message should name %s: %q", source, err) + } + } + if strings.Contains(err.Error(), util.INFISICAL_WORKSPACE_CONFIG_FILE_NAME) { + t.Errorf("the message should not offer a project file: %q", err) + } +} + +func TestAgentVaultProjectHintsOnlyFor404FromTheSharedVariable(t *testing.T) { + notFound := &api.APIError{StatusCode: 404} + shared := agentVaultProject{ID: projectFromProjEnv, Source: util.INFISICAL_PROJECT_ID_NAME} + + if hints := agentVaultProjectHints(notFound, shared); len(hints) != 1 || !strings.Contains(hints[0], "Secrets Management") || !strings.Contains(hints[0], agentVaultProjectIDEnv) { + t.Fatalf("hints %v", hints) + } + if hints := agentVaultProjectHints(notFound, agentVaultProject{Source: agentVaultProjectIDEnv}); hints != nil { + t.Fatalf("a project from %s needs no hint: %v", agentVaultProjectIDEnv, hints) + } + if hints := agentVaultProjectHints(&api.APIError{StatusCode: 403}, shared); hints != nil { + t.Fatalf("a 403 needs no hint: %v", hints) + } + if hints := agentVaultProjectHints(&api.APIError{StatusCode: 404, DocumentationURL: "https://infisical.com/docs"}, shared); hints != nil { + t.Fatalf("a missing route is not a missing project: %v", hints) + } +} + +func captureAgentVaultStdout(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + old := util.GetStdoutWriter + util.GetStdoutWriter = func() io.Writer { return &buf } + t.Cleanup(func() { util.GetStdoutWriter = old }) + return &buf +} + +func TestPrintAgentVaultProjectForProfile(t *testing.T) { + isolateAgentVaultProject(t) + + out := captureAgentVaultStdout(t) + printAgentVaultProjectForProfile() + if out.Len() != 0 { + t.Fatalf("nothing set should print nothing, printed %q", out) + } + + t.Setenv(util.INFISICAL_PROJECT_ID_NAME, projectFromProjEnv) + t.Setenv(agentVaultProjectIDEnv, projectFromAgvEnv) + printAgentVaultProjectForProfile() + if want := "Agent Vault project: " + projectFromAgvEnv + "\nAgent Vault project via: " + agentVaultProjectIDEnv + "\n"; out.String() != want { + t.Fatalf("printed %q, want %q", out, want) + } + + out.Reset() + t.Setenv(agentVaultProjectIDEnv, "my-agent-vault") + printAgentVaultProjectForProfile() + if !strings.HasPrefix(out.String(), "Agent Vault project: ") || !strings.Contains(out.String(), `"my-agent-vault" from `+agentVaultProjectIDEnv) { + t.Fatalf("printed %q", out) + } +} + +func TestAgentVaultProjectSourceKind(t *testing.T) { + for source, want := range map[string]string{ + "--project-id": "flag", + agentVaultProjectIDEnv: "agent-vault-env", + util.INFISICAL_PROJECT_ID_NAME: "project-env", + } { + if got := agentVaultProjectSourceKind(agentVaultProject{Source: source}); got != want { + t.Errorf("%s: got %s, want %s", source, got, want) + } + } +} + +func TestResolveAgentVaultProjectNeverPrintsATokenPastedAsTheProject(t *testing.T) { + isolateAgentVaultProject(t) + // The shape the server mints: agv_ then 32 random bytes in base64url. + const token = "agv_Xq3vR8kLmN2pQ7sT1uW4yZ6aB9cD0eF5gH-jK_oPqRs" + t.Setenv(util.INFISICAL_PROJECT_ID_NAME, token) + + _, err := resolveAgentVaultProject(projectIDCmd(t)) + if err == nil || strings.Contains(err.Error(), token) || !strings.Contains(err.Error(), " from "+util.INFISICAL_PROJECT_ID_NAME) { + t.Fatalf("got %v", err) + } +} + +func TestEchoAgentVaultProjectStaysQuietUnderSilentAndStructuredOutput(t *testing.T) { + project := agentVaultProject{ID: projectFromProjEnv, Source: util.INFISICAL_PROJECT_ID_NAME} + newCmd := func(args ...string) *cobra.Command { + cmd := &cobra.Command{Use: "create", Run: func(*cobra.Command, []string) {}} + cmd.Flags().Bool("silent", false, "") + cmd.Flags().StringP("output", "o", "", "") + if err := cmd.Flags().Parse(args); err != nil { + t.Fatal(err) + } + return cmd + } + + for _, args := range [][]string{{"--silent"}, {"-o", "json"}, {"-o", "yaml"}} { + stderr := captureStderr(t) + echoAgentVaultProject(newCmd(args...), project) + if stderr.Len() != 0 { + t.Fatalf("%v: printed %q", args, stderr.String()) + } + } + + stderr := captureStderr(t) + echoAgentVaultProject(newCmd(), project) + if !strings.Contains(stderr.String(), projectFromProjEnv) { + t.Fatalf("without --silent or -o: printed %q", stderr.String()) + } +} diff --git a/packages/cmd/agent_vault_run.go b/packages/cmd/agent_vault_run.go index b313a56a..63c07683 100644 --- a/packages/cmd/agent_vault_run.go +++ b/packages/cmd/agent_vault_run.go @@ -113,7 +113,9 @@ Two ways to get a session, exactly one of them required: --access-bundle mint one now over the named bundle, or set INFISICAL_AGENT_VAULT_ACCESS_BUNDLE (needs your login or a machine identity). Revoked when the agent exits unless - --keep-session is set. + --keep-session is set. The bundle is looked up in the Agent Vault project from + --project-id, or else INFISICAL_AGENT_VAULT_PROJECT_ID or INFISICAL_PROJECT_ID, + in that order. --session-token run with a session token minted in the dashboard, or set INFISICAL_AGENT_VAULT_SESSION_TOKEN. Never revoked by this command. @@ -203,7 +205,7 @@ func resolveAgentVaultAccessBundles(cmd *cobra.Command) (bundles []string, sourc return nil, agentVaultSourceNone, err } if bundles = nonBlank(values); len(bundles) == 0 { - return nil, agentVaultSourceFlag, fmt.Errorf("--access-bundle was given but names no bundle; pass a name like 'coding-agent'") + return nil, agentVaultSourceFlag, errAgentVaultEmptyAccessBundle() } return bundles, agentVaultSourceFlag, nil } @@ -260,6 +262,27 @@ func resolveAgentVaultSessionLabel(cmd *cobra.Command, sessionToken string, toke return strings.TrimSpace(label), nil } +// None of these can reach a session minted in the dashboard: its expiry and project were fixed when it was +// created, and this command never revokes it. +func refuseAgentVaultMintOnlyFlags(cmd *cobra.Command, sessionToken string, tokenSource agentVaultSource) error { + if sessionToken == "" { + return nil + } + tokenName := agentVaultSourceName(tokenSource, "--session-token", agentVaultSessionTokenEnv) + switch { + case cmd.Flags().Changed("ttl"): + return fmt.Errorf( + "--ttl applies to a session this command mints; the one behind %s was given its lifetime in the dashboard. Drop --ttl, or mint here with --access-bundle", tokenName) + case cmd.Flags().Changed("keep-session"): + return fmt.Errorf( + "--keep-session applies to a session this command mints; the one behind %s is never revoked here. Drop --keep-session, or mint here with --access-bundle", tokenName) + case cmd.Flags().Changed("project-id"): + return fmt.Errorf( + "--project-id applies to a session this command mints; the one behind %s already belongs to a project. Drop --project-id, or mint here with --access-bundle", tokenName) + } + return nil +} + func runAgentVaultRun(cmd *cobra.Command, args []string) { sessionToken, tokenSource, err := resolveAgentVaultSessionToken(cmd) if err != nil { @@ -287,14 +310,8 @@ func runAgentVaultRun(cmd *cobra.Command, args []string) { tokenSource = agentVaultSourceNone } - if len(accessBundles) > 1 { - util.HandleError(fmt.Errorf("a session carries one access bundle; pass --access-bundle once")) - } - for _, name := range accessBundles { - if !agentVaultBundleNameRe.MatchString(name) { - util.HandleError(fmt.Errorf("%q from %s is not an access bundle name; names are lowercase letters, numbers and hyphens, for example 'coding-agent'", - name, agentVaultSourceName(bundleSource, "--access-bundle", agentVaultAccessBundleEnv))) - } + if err := validateAgentVaultAccessBundles(accessBundles, bundleSource); err != nil { + util.HandleError(err) } skillsDir, noSkills, err := resolveAgentVaultRunSkills(cmd) @@ -308,16 +325,14 @@ func runAgentVaultRun(cmd *cobra.Command, args []string) { util.HandleError(err) } - // Neither flag can reach a session minted in the dashboard: its expiry was fixed when it was created. - if sessionToken != "" { - tokenName := agentVaultSourceName(tokenSource, "--session-token", agentVaultSessionTokenEnv) - if cmd.Flags().Changed("ttl") { - util.HandleError(fmt.Errorf( - "--ttl applies to a session this command mints; the one behind %s was given its lifetime in the dashboard. Drop --ttl, or mint here with --access-bundle", tokenName)) - } - if cmd.Flags().Changed("keep-session") { - util.HandleError(fmt.Errorf( - "--keep-session applies to a session this command mints; the one behind %s is never revoked here. Drop --keep-session, or mint here with --access-bundle", tokenName)) + if err := refuseAgentVaultMintOnlyFlags(cmd, sessionToken, tokenSource); err != nil { + util.HandleError(err) + } + + var project agentVaultProject + if len(accessBundles) > 0 { + if project, err = resolveAgentVaultProject(cmd); err != nil { + util.HandleError(err) } } @@ -413,18 +428,8 @@ func runAgentVaultRun(cmd *cobra.Command, args []string) { var minted *api.AgentVaultSession if len(accessBundles) > 0 { identity := resolveAgentVaultIdentityToken(cmd) - httpClient, clientErr := util.GetRestyClientWithCustomHeaders() - if clientErr != nil { - util.HandleError(clientErr, "Failed to build the API client") - } - httpClient.SetAuthToken(identity) - - created, mintErr := api.CallCreateAgentVaultSession(httpClient, api.CreateAgentVaultSessionRequest{AccessBundle: accessBundles[0], TTL: ttl, Label: label}) - if mintErr != nil { - util.HandleError(mintErr, "Unable to mint an Agent Vault session") - } - minted = &created.Session - sessionToken = created.Session.Token + minted = mintAgentVaultSessionOrExit(identity, project, accessBundles, ttl, label) + sessionToken = minted.Token Telemetry.SetActor(telemetry.IdentityClaimsFromToken(identity)) } @@ -441,7 +446,7 @@ func runAgentVaultRun(cmd *cobra.Command, args []string) { env := buildAgentVaultRunEnv(os.Environ(), proxyAddr, sessionToken, caPath, extraNoProxy) - printAgentVaultRunSummary(proxyAddr, caResp, servedFingerprint, minted, tokenSource, bundleSource) + printAgentVaultRunSummary(proxyAddr, caResp, servedFingerprint, minted, project, tokenSource, bundleSource) if exitCode, interrupted := runAgentVaultRunSkillsStep(noSkills, func(ctx context.Context) { @@ -633,6 +638,12 @@ func revocationToken(ctx context.Context, cmd *cobra.Command, loginPolicy util.R } func resolveAgentVaultIdentityToken(cmd *cobra.Command) string { + return resolveAgentVaultIdentity(cmd, true) +} + +// Without the wizard, an expired or missing login is an error rather than an interactive sign-in, for a command +// whose output is being captured and so has nobody watching its prompts. +func resolveAgentVaultIdentity(cmd *cobra.Command, allowLoginWizard bool) string { clientID, _ := util.GetCmdFlagOrEnvWithDefaultValue(cmd, "client-id", []string{util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME}, "") clientSecret, _ := util.GetCmdFlagOrEnvWithDefaultValue(cmd, "client-secret", []string{util.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME}, "") if clientID != "" && clientSecret != "" { @@ -651,7 +662,13 @@ func resolveAgentVaultIdentityToken(cmd *cobra.Command) string { } details, err := util.GetCurrentLoggedInUserDetails(true) - if err != nil || !details.IsUserLoggedIn || details.LoginExpired { + if err = agentVaultLoginReadError(err); err != nil { + util.HandleError(err, "Unable to read your Infisical login") + } + if !details.IsUserLoggedIn || details.LoginExpired { + if !allowLoginWizard { + util.HandleError(fmt.Errorf("your Infisical login has expired or is missing, and this command's output is being captured, so it can't sign you in. Run 'infisical login' first, set %s, or pass --client-id and --client-secret", util.INFISICAL_TOKEN_NAME)) + } details = util.EstablishUserLoginSession() } if details.UserCredentials.JTWToken == "" { @@ -661,6 +678,15 @@ func resolveAgentVaultIdentityToken(cmd *cobra.Command) string { return details.UserCredentials.JTWToken } +// A login missing from the keyring is a missing login, which signing in fixes. Any other error (a broken config +// file, an unreadable keyring, a profile for another domain) would not be fixed by the wizard, so it is shown. +func agentVaultLoginReadError(err error) error { + if err == nil || errors.Is(err, util.ErrUserNotLoggedIn) { + return nil + } + return err +} + // Windows resolves variable names case-insensitively, so os.Getenv finds a lowercase spelling and the run // uses it, while an exact match here would hand that same spelling to the agent. func isAgentVaultSessionTokenKey(key string) bool { @@ -788,7 +814,7 @@ func agentVaultFingerprintsEqual(a, b string) bool { return normalize(a) != "" && normalize(a) == normalize(b) } -func printAgentVaultRunSummary(proxyAddr string, ca agentVaultProxyCa, fingerprint string, minted *api.AgentVaultSession, tokenSource, bundleSource agentVaultSource) { +func printAgentVaultRunSummary(proxyAddr string, ca agentVaultProxyCa, fingerprint string, minted *api.AgentVaultSession, project agentVaultProject, tokenSource, bundleSource agentVaultSource) { dim := color.HiBlackString fmt.Fprintln(os.Stderr, color.GreenString("Starting agent behind Agent Vault proxy %q at %s", ca.Name, proxyAddr)) fmt.Fprintln(os.Stderr, dim("proxy CA fingerprint: "+fingerprint)) @@ -800,6 +826,7 @@ func printAgentVaultRunSummary(proxyAddr string, ca agentVaultProxyCa, fingerpri fmt.Fprintln(os.Stderr, dim("access bundle from "+agentVaultAccessBundleEnv)) } if minted != nil { + fmt.Fprintln(os.Stderr, dim(fmt.Sprintf("Agent Vault project %s from %s", project.ID, project.Source))) expiry := "never" if minted.ExpiresAt != nil { expiry = *minted.ExpiresAt @@ -841,6 +868,7 @@ func init() { agentVaultRunCmd.Flags().String("session-token", "", "run with a session token minted in the dashboard instead of minting one (falls back to "+agentVaultSessionTokenEnv+")") agentVaultRunCmd.Flags().String("ttl", "never", "lifetime of the session this command creates: one number and one unit, such as 30m, 8h or 7d (not 2h30m), or never") agentVaultRunCmd.Flags().Bool("keep-session", false, "leave a minted session active when the agent exits") + agentVaultRunCmd.Flags().String("project-id", "", "Agent Vault project of the access bundle to mint over (falls back to "+agentVaultProjectIDEnv+", then INFISICAL_PROJECT_ID)") agentVaultRunCmd.Flags().String("label", "", "describe what the session this command creates is for (up to 128 characters)") agentVaultRunCmd.Flags().String("proxy", "", "address of the Agent Vault proxy as host:port (falls back to "+agentVaultProxyAddressEnv+")") agentVaultRunCmd.Flags().String("ca-fingerprint", "", "abort unless the proxy's certificate authority matches this SHA256 fingerprint from Infisical") diff --git a/packages/cmd/agent_vault_run_skills.go b/packages/cmd/agent_vault_run_skills.go index a6dfe367..53afb0c6 100644 --- a/packages/cmd/agent_vault_run_skills.go +++ b/packages/cmd/agent_vault_run_skills.go @@ -46,7 +46,8 @@ func newAgentVaultSkillsHTTPClient() (*resty.Client, error) { } // A session token goes only to a domain the user chose: --domain, the domain variables, or the logged-in -// profile. A .infisical.json can arrive with a cloned repo, so the domain it names never receives the token. +// profile, else US Cloud. A command holding only the token has not loaded the profile, so nothing else would +// point it at the profile's domain. func useAgentVaultSessionTokenDomain() { if !config.INFISICAL_DOMAIN_EXPLICITLY_SET { config.INFISICAL_URL = agentVaultSessionTokenAPIURL() @@ -58,11 +59,7 @@ func agentVaultSessionTokenAPIURL() string { if domain == "" { domain = util.INFISICAL_DEFAULT_US_URL } - apiURL := util.AppendAPIEndpoint(domain) - if fileDomain, valid := util.GetDomainFromFile(); valid && util.AppendAPIEndpoint(fileDomain) != apiURL { - util.PrintWarning(fmt.Sprintf("Ignoring the domain in .infisical.yaml (or .infisical.json) for this session token, and getting skills from %s. Pass --domain to choose another.", strings.TrimSuffix(apiURL, "/api"))) - } - return apiURL + return util.AppendAPIEndpoint(domain) } // The waits before each retry of a list that answered 401 on a minted run. diff --git a/packages/cmd/agent_vault_run_test.go b/packages/cmd/agent_vault_run_test.go index da971758..cc8ae171 100644 --- a/packages/cmd/agent_vault_run_test.go +++ b/packages/cmd/agent_vault_run_test.go @@ -8,6 +8,8 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/pem" + "errors" + "fmt" "math/big" "net/http" "net/http/httptest" @@ -521,3 +523,21 @@ func TestResolveAgentVaultSessionLabelRefusesASessionToken(t *testing.T) { } } } + +func TestAgentVaultLoginReadErrorShowsAllButAMissingLogin(t *testing.T) { + if err := agentVaultLoginReadError(nil); err != nil { + t.Fatalf("no error: %v", err) + } + if err := agentVaultLoginReadError(util.ErrUserNotLoggedIn); err != nil { + t.Fatalf("a missing login should go to the sign-in path, not be shown as an error: %v", err) + } + for _, want := range []error{ + fmt.Errorf("%w: profile 'work' belongs to eu.infisical.com", util.ErrProfileDomainMismatch), + fmt.Errorf("%w: profile 'work' does not exist", util.ErrProfileNotFound), + errors.New("failed to fetch credentials from keyring because [err=locked]"), + } { + if got := agentVaultLoginReadError(want); got != want { + t.Fatalf("got %v, want %v", got, want) + } + } +} diff --git a/packages/cmd/agent_vault_sessions.go b/packages/cmd/agent_vault_sessions.go new file mode 100644 index 00000000..d6f48ac7 --- /dev/null +++ b/packages/cmd/agent_vault_sessions.go @@ -0,0 +1,299 @@ +package cmd + +import ( + "cmp" + "context" + "errors" + "fmt" + "net/http" + "os" + "runtime" + "strings" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/telemetry" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/go-resty/resty/v2" + "github.com/google/uuid" + "github.com/mattn/go-isatty" + "github.com/posthog/posthog-go" + "github.com/spf13/cobra" +) + +var agentVaultSessionsCmd = &cobra.Command{ + Use: "sessions", + Short: "Create and revoke Agent Vault sessions", +} + +var agentVaultSessionsCreateCmd = &cobra.Command{ + Use: "create --access-bundle ", + Short: "Create a session over an access bundle and print its token", + Long: `Create a session over an access bundle and print its token. + +The session belongs to the Agent Vault project from --project-id, or else INFISICAL_AGENT_VAULT_PROJECT_ID or +INFISICAL_PROJECT_ID, in that order. + +Only the token is printed, so it can be captured into a variable: + + export INFISICAL_AGENT_VAULT_SESSION_TOKEN=$(infisical agent-vault sessions create --access-bundle coding-agent --ttl 8h) + +Pass -o json or -o yaml for the session's ID, project, access bundle, label and expiry as well. The session +stays active until it expires or you revoke it with 'infisical agent-vault sessions revoke'.`, + Example: ` infisical agent-vault sessions create --access-bundle coding-agent --ttl 8h + infisical agent-vault sessions create --project-id 4f0c... --access-bundle coding-agent --label "nightly deploy" -o json`, + DisableFlagsInUseLine: true, + Args: cobra.NoArgs, + Run: runAgentVaultSessionsCreate, +} + +type agentVaultSessionOutput struct { + ID string `json:"id"` + Token string `json:"token"` + ProjectID string `json:"projectId"` + AccessBundle string `json:"accessBundle"` + Label *string `json:"label"` + ExpiresAt *string `json:"expiresAt"` +} + +func runAgentVaultSessionsCreate(cmd *cobra.Command, args []string) { + outputFormat, err := cmd.Flags().GetString("output") + if err != nil { + util.HandleError(err, "Unable to read --output") + } + // Checked before the session exists, so a typo never leaves one behind with its token unprinted. + if outputFormat != "" && outputFormat != util.OUTPUT_FORMAT_JSON && outputFormat != util.OUTPUT_FORMAT_YAML { + exitWithError(cmd, errAgentVaultInvalidOutput(outputFormat)) + } + + accessBundles, bundleSource, err := resolveAgentVaultAccessBundles(cmd) + if err != nil { + exitWithError(cmd, err) + } + if bundleSource == agentVaultSourceNone { + exitWithError(cmd, errAgentVaultAccessBundleRequired()) + } + if err := validateAgentVaultAccessBundles(accessBundles, bundleSource); err != nil { + exitWithError(cmd, err) + } + + project, err := resolveAgentVaultProject(cmd) + if err != nil { + exitWithError(cmd, err) + } + echoAgentVaultProject(cmd, project) + + ttl, _ := cmd.Flags().GetString("ttl") + label, _ := cmd.Flags().GetString("label") + label = strings.TrimSpace(label) + + stdoutIsTerminal := isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) + identity := resolveAgentVaultIdentity(cmd, stdoutIsTerminal) + minted, err := mintAgentVaultSession(newAgentVaultAPIClient(identity), project, accessBundles, ttl, label) + if err != nil { + exitWithError(cmd, classifyAgentVaultAPIError(err, project, accessBundles[0]), + append([]string{"Unable to mint an Agent Vault session"}, agentVaultProjectHints(err, project)...)...) + } + + Telemetry.SetActor(telemetry.IdentityClaimsFromToken(identity)) + Telemetry.CaptureEvent("cli-command:agent-vault sessions create", posthog.NewProperties(). + Set("version", util.CLI_VERSION). + Set("platform", runtime.GOOS). + Set("ttl", ttl). + Set("labeled", label != ""). + Set("projectSource", agentVaultProjectSourceKind(project)). + Set("output", outputFormat)) + + if outputFormat == "" { + fmt.Println(minted.Token) + return + } + formatted, err := util.FormatOutput(outputFormat, agentVaultSessionOutputFrom(minted, project, accessBundles[0]), nil) + if err != nil { + util.HandleError(err, "Unable to format the session") + } + fmt.Println(strings.TrimRight(formatted, "\n")) +} + +func agentVaultSessionOutputFrom(minted api.AgentVaultSession, project agentVaultProject, accessBundle string) agentVaultSessionOutput { + out := agentVaultSessionOutput{ + ID: minted.ID, + Token: minted.Token, + ProjectID: cmp.Or(minted.ProjectID, project.ID), + AccessBundle: accessBundle, + Label: minted.Label, + ExpiresAt: minted.ExpiresAt, + } + if minted.AccessBundle != nil { + out.AccessBundle = minted.AccessBundle.Name + } + return out +} + +var agentVaultSessionsRevokeCmd = &cobra.Command{ + Use: "revoke [session-id]", + Short: "Revoke a session by its ID or its token", + Long: `Revoke a session by its ID or its token. + +By ID, the session is revoked as you, or as the machine identity from --client-id and --client-secret, which +needs access to it. Without an ID, the session token from --session-token, or else +INFISICAL_AGENT_VAULT_SESSION_TOKEN, names the session, and the token alone is enough: whoever holds it can end +the session. Revoking a session that has already ended succeeds.`, + Example: ` infisical agent-vault sessions revoke + infisical agent-vault sessions revoke 0b6f3a1e-... + infisical agent-vault sessions revoke --session-token "$TOKEN"`, + DisableFlagsInUseLine: true, + Args: cobra.MaximumNArgs(1), + Run: runAgentVaultSessionsRevoke, +} + +func runAgentVaultSessionsRevoke(cmd *cobra.Command, args []string) { + token, err := validateAgentVaultRevokeTarget(cmd, args) + if err != nil { + util.HandleError(err) + } + + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + util.HandleError(err, "Failed to build the API client") + } + + byToken := token != "" + var identity string + if !byToken { + // Resolved before the deadline starts: it can open the login flow, which takes as long as the person does. + identity = resolveAgentVaultIdentityToken(cmd) + httpClient.SetAuthToken(identity) + } + + ctx, cancel := context.WithTimeout(cmd.Context(), agentVaultExitRevokeTimeout) + defer cancel() + + var sessionID string + if byToken { + sessionID, err = revokeAgentVaultSessionByToken(ctx, httpClient, token) + if err != nil { + util.HandleError(err, agentVaultRevokeErrorMessages(ctx, err, true)...) + } + } else { + sessionID = args[0] + if err := api.CallRevokeAgentVaultSession(ctx, httpClient, sessionID); err != nil { + util.HandleError(err, agentVaultRevokeErrorMessages(ctx, err, false)...) + } + Telemetry.SetActor(telemetry.IdentityClaimsFromToken(identity)) + } + + Telemetry.CaptureEvent("cli-command:agent-vault sessions revoke", posthog.NewProperties(). + Set("version", util.CLI_VERSION). + Set("platform", runtime.GOOS). + Set("by", map[bool]string{true: "token", false: "id"}[byToken])) + + fmt.Printf("Session %s is revoked\n", sessionID) +} + +// validateAgentVaultRevokeTarget returns the session token to revoke by, or "" to revoke the session ID in args. +// A token in the environment is ignored when an ID is given, since the ID names the session outright. +func validateAgentVaultRevokeTarget(cmd *cobra.Command, args []string) (string, error) { + if len(args) == 1 { + if cmd.Flags().Changed("session-token") { + return "", errors.New("pass a session ID or --session-token, not both") + } + // The argument is never echoed: a token passed here by mistake would otherwise land in the terminal or a CI log. + if strings.HasPrefix(strings.TrimSpace(args[0]), "agv_") { + return "", fmt.Errorf("that looks like a session token, not a session ID. Pass it with --session-token, or set %s", agentVaultSessionTokenEnv) + } + if _, err := uuid.Parse(args[0]); err != nil || len(args[0]) != 36 { + return "", errors.New("the argument is not a session ID; pass the ID shown on the session in Infisical, or revoke by --session-token") + } + return "", nil + } + + token, _, err := resolveAgentVaultSessionToken(cmd) + if err != nil { + return "", err + } + if token == "" { + return "", fmt.Errorf("name the session to revoke: pass its ID, or --session-token, or set %s", agentVaultSessionTokenEnv) + } + return token, nil +} + +func revokeAgentVaultSessionByToken(ctx context.Context, httpClient *resty.Client, token string) (string, error) { + useAgentVaultSessionTokenDomain() + res, err := api.CallRevokeAgentVaultSessionByToken(ctx, httpClient, token) + if err != nil { + return "", err + } + return res.Session.ID, nil +} + +func agentVaultRevokeErrorMessages(ctx context.Context, err error, byToken bool) []string { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return []string{fmt.Sprintf("Infisical did not answer within %s, so the session may still be active. Try again, or revoke it in Infisical", agentVaultExitRevokeTimeout)} + } + var apiErr *api.APIError + if byToken && errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnauthorized { + return []string{"Infisical does not recognize this session token. Check that it was copied whole"} + } + return []string{"Unable to revoke the session"} +} + +// The variable names one bundle and the flag is repeatable only so that passing it twice is refused by name. +func validateAgentVaultAccessBundles(bundles []string, source agentVaultSource) error { + if len(bundles) > 1 { + return errAgentVaultOneAccessBundle() + } + for _, name := range bundles { + if !agentVaultBundleNameRe.MatchString(name) { + return errAgentVaultInvalidAccessBundle(name, source) + } + } + return nil +} + +func mintAgentVaultSession(httpClient *resty.Client, project agentVaultProject, accessBundles []string, ttl, label string) (api.AgentVaultSession, error) { + created, err := api.CallCreateAgentVaultSession(httpClient, api.CreateAgentVaultSessionRequest{ + ProjectID: project.ID, + AccessBundle: accessBundles[0], + TTL: ttl, + Label: label, + }) + if err != nil { + return api.AgentVaultSession{}, err + } + return created.Session, nil +} + +func newAgentVaultAPIClient(identity string) *resty.Client { + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + util.HandleError(err, "Failed to build the API client") + } + httpClient.SetAuthToken(identity) + return httpClient +} + +func mintAgentVaultSessionOrExit(identity string, project agentVaultProject, accessBundles []string, ttl, label string) *api.AgentVaultSession { + minted, err := mintAgentVaultSession(newAgentVaultAPIClient(identity), project, accessBundles, ttl, label) + if err != nil { + util.HandleError(err, append([]string{"Unable to mint an Agent Vault session"}, agentVaultProjectHints(err, project)...)...) + } + return &minted +} + +func init() { + agentVaultSessionsCreateCmd.Flags().String("project-id", "", "Agent Vault project to create the session in (falls back to "+agentVaultProjectIDEnv+", then INFISICAL_PROJECT_ID)") + agentVaultSessionsCreateCmd.Flags().StringArray("access-bundle", nil, "create the session over the access bundle with this `name` (falls back to "+agentVaultAccessBundleEnv+")") + agentVaultSessionsCreateCmd.Flags().String("ttl", "never", "lifetime of the session: one number and one unit, such as 30m, 8h or 7d (not 2h30m), or never") + agentVaultSessionsCreateCmd.Flags().String("label", "", "describe what the session is for (up to 128 characters)") + agentVaultSessionsCreateCmd.Flags().String("client-id", "", "universal auth client id of the machine identity that creates the session (falls back to INFISICAL_UNIVERSAL_AUTH_CLIENT_ID)") + agentVaultSessionsCreateCmd.Flags().String("client-secret", "", "universal auth client secret of that machine identity (falls back to INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET)") + agentVaultSessionsCreateCmd.Flags().StringP("output", "o", "", "print the session's ID, token, project, access bundle, label and expiry as json or yaml, instead of only its token") + + agentVaultSessionsRevokeCmd.Flags().String("session-token", "", "revoke the session this session token belongs to, instead of naming it by ID (falls back to "+agentVaultSessionTokenEnv+" when no ID is given)") + agentVaultSessionsRevokeCmd.Flags().String("client-id", "", "universal auth client id of the machine identity to revoke as (falls back to INFISICAL_UNIVERSAL_AUTH_CLIENT_ID)") + agentVaultSessionsRevokeCmd.Flags().String("client-secret", "", "universal auth client secret of that machine identity (falls back to INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET)") + + agentVaultSessionsCmd.AddCommand(agentVaultSessionsCreateCmd) + agentVaultSessionsCmd.AddCommand(agentVaultSessionsRevokeCmd) + agentVaultCmd.AddCommand(agentVaultSessionsCmd) +} diff --git a/packages/cmd/agent_vault_sessions_test.go b/packages/cmd/agent_vault_sessions_test.go new file mode 100644 index 00000000..686f4c7d --- /dev/null +++ b/packages/cmd/agent_vault_sessions_test.go @@ -0,0 +1,275 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/config" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/go-resty/resty/v2" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +func TestMintAgentVaultSessionSendsTheProject(t *testing.T) { + var sent map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/agent-vault/sessions" { + http.NotFound(w, r) + return + } + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &sent) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"session":{"id":"s1","token":"agv_x","projectId":"` + projectFromFlag + `","label":null,"expiresAt":null,"createdAt":"2026-11-04T00:00:00Z","accessBundle":{"id":"b1","name":"coding-agent"}}}`)) + })) + t.Cleanup(srv.Close) + old := config.INFISICAL_URL + config.INFISICAL_URL = srv.URL + "/api" + t.Cleanup(func() { config.INFISICAL_URL = old }) + + minted, err := mintAgentVaultSession(resty.New(), agentVaultProject{ID: projectFromFlag}, []string{"coding-agent"}, "8h", "") + if err != nil { + t.Fatal(err) + } + if sent["projectId"] != projectFromFlag || sent["accessBundle"] != "coding-agent" || sent["ttl"] != "8h" { + t.Fatalf("body %v", sent) + } + if _, ok := sent["label"]; ok { + t.Fatalf("an empty label should stay out of the body: %v", sent) + } + if minted.Token != "agv_x" || minted.ProjectID != projectFromFlag || minted.AccessBundle == nil || minted.AccessBundle.Name != "coding-agent" { + t.Fatalf("minted %+v", minted) + } +} + +func TestAgentVaultSessionOutputFormats(t *testing.T) { + label, expires := "nightly deploy", "2026-11-05T00:00:00Z" + out := agentVaultSessionOutputFrom(api.AgentVaultSession{ + ID: "s1", Token: "agv_x", ProjectID: projectFromFlag, Label: &label, ExpiresAt: &expires, + AccessBundle: &api.AgentVaultSessionAccessBundle{ID: "b1", Name: "coding-agent"}, + }, agentVaultProject{ID: projectFromFlag}, "coding-agent") + want := map[string]any{ + "id": "s1", "token": "agv_x", "projectId": projectFromFlag, "accessBundle": "coding-agent", + "label": label, "expiresAt": expires, + } + + for _, format := range []string{util.OUTPUT_FORMAT_JSON, util.OUTPUT_FORMAT_YAML} { + formatted, err := util.FormatOutput(format, out, nil) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if format == util.OUTPUT_FORMAT_JSON { + err = json.Unmarshal([]byte(formatted), &got) + } else { + err = yaml.Unmarshal([]byte(formatted), &got) + } + if err != nil { + t.Fatalf("%s: %v", format, err) + } + if len(got) != len(want) { + t.Fatalf("%s: keys %v, want %v", format, got, want) + } + for key, value := range want { + if got[key] != value { + t.Errorf("%s: %s = %v, want %v", format, key, got[key], value) + } + } + } +} + +func TestAgentVaultSessionOutputWithNoExpiryOrLabelPrintsNull(t *testing.T) { + formatted, err := util.FormatOutput(util.OUTPUT_FORMAT_JSON, agentVaultSessionOutputFrom(api.AgentVaultSession{ID: "s1", Token: "agv_x"}, agentVaultProject{ID: projectFromFlag}, "coding-agent"), nil) + if err != nil { + t.Fatal(err) + } + for _, part := range []string{`"expiresAt":null`, `"label":null`, `"projectId":"` + projectFromFlag + `"`, `"accessBundle":"coding-agent"`} { + if !strings.Contains(formatted, part) { + t.Errorf("want %s in %s", part, formatted) + } + } +} + +func TestValidateAgentVaultRevokeTarget(t *testing.T) { + id := "0b6f3a1e-1111-4111-8111-111111111111" + cases := []struct { + name string + args []string + envToken string + wantToken string + wantErr string + }{ + {"by id", []string{id}, "", "", ""}, + {"by id ignores the variable", []string{id}, "agv_env", "", ""}, + {"by token", []string{"--session-token", " agv_x "}, "", "agv_x", ""}, + {"the flag beats the variable", []string{"--session-token", "agv_x"}, "agv_env", "agv_x", ""}, + {"by the variable", nil, "agv_env", "agv_env", ""}, + {"both", []string{id, "--session-token", "agv_x"}, "", "", "not both"}, + {"neither", nil, "", "", "name the session to revoke"}, + {"empty token", []string{"--session-token", " "}, "agv_env", "", "--session-token was given but is empty"}, + {"a token as the argument", []string{"agv_secretvalue"}, "", "", "looks like a session token, not a session ID"}, + {"not an id", []string{"secret-ish-value"}, "", "", "the argument is not a session ID"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(agentVaultSessionTokenEnv, tc.envToken) + cmd := &cobra.Command{Use: "revoke", Run: func(*cobra.Command, []string) {}} + cmd.Flags().String("session-token", "", "") + if err := cmd.Flags().Parse(tc.args); err != nil { + t.Fatal(err) + } + + token, err := validateAgentVaultRevokeTarget(cmd, cmd.Flags().Args()) + if tc.wantErr == "" { + if err != nil { + t.Fatal(err) + } + if token != tc.wantToken { + t.Fatalf("token %q, want %q", token, tc.wantToken) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err %v, want %q", err, tc.wantErr) + } + for _, arg := range cmd.Flags().Args() { + if strings.Contains(err.Error(), arg) { + t.Fatalf("the error echoes the argument %q: %v", arg, err) + } + } + }) + } +} + +func fakeAgentVaultRevokeServer(t *testing.T, auth, path *string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *auth, *path = r.Header.Get("Authorization"), r.Method+" "+r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"session":{"id":"s1","revokedAt":"2026-11-04T00:00:00Z"}}`)) + })) + t.Cleanup(srv.Close) + return srv +} + +func useAgentVaultDomainExplicitlySet(t *testing.T, set bool) { + t.Helper() + old := config.INFISICAL_DOMAIN_EXPLICITLY_SET + config.INFISICAL_DOMAIN_EXPLICITLY_SET = set + t.Cleanup(func() { config.INFISICAL_DOMAIN_EXPLICITLY_SET = old }) +} + +func TestRevokeAgentVaultSessionByTokenSendsOnlyTheToken(t *testing.T) { + var auth, path string + srv := fakeAgentVaultRevokeServer(t, &auth, &path) + useInfisicalURL(t, srv.URL+"/api") + useAgentVaultDomainExplicitlySet(t, true) + + id, err := revokeAgentVaultSessionByToken(context.Background(), resty.New(), "agv_x") + if err != nil { + t.Fatal(err) + } + if id != "s1" || auth != "Bearer agv_x" || path != "POST /api/v1/agent-vault/session/revoke" { + t.Fatalf("id %q, auth %q, path %q", id, auth, path) + } +} + +func TestRevokeAgentVaultSessionByTokenIgnoresTheDomainInARepoFile(t *testing.T) { + var auth, path string + srv := fakeAgentVaultRevokeServer(t, &auth, &path) + home := isolateSkillsHome(t) + if err := os.MkdirAll(filepath.Join(home, util.CONFIG_FOLDER_NAME), 0o700); err != nil { + t.Fatal(err) + } + profile := `{"LoggedInUserDomain":"` + srv.URL + `"}` + if err := os.WriteFile(filepath.Join(home, util.CONFIG_FOLDER_NAME, util.CONFIG_FILE_NAME), []byte(profile), 0o600); err != nil { + t.Fatal(err) + } + repo := t.TempDir() + if err := os.WriteFile(filepath.Join(repo, util.INFISICAL_WORKSPACE_CONFIG_FILE_NAME), []byte("version: 1\ngeneral:\n domain: https://evil.example\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Chdir(repo) + useInfisicalURL(t, "https://evil.example/api") + useAgentVaultDomainExplicitlySet(t, false) + warnings := captureWarnings(t) + + if _, err := revokeAgentVaultSessionByToken(context.Background(), resty.New(), "agv_x"); err != nil { + t.Fatal(err) + } + if auth != "Bearer agv_x" || config.INFISICAL_URL != srv.URL+"/api" { + t.Fatalf("the token went to %s (auth %q)", config.INFISICAL_URL, auth) + } + if warnings.Len() != 0 { + t.Fatalf("an Agent Vault command does not read the file, so has nothing to warn about: %q", warnings.String()) + } +} + +func TestRevokeAgentVaultSessionStopsAtItsDeadline(t *testing.T) { + var auth, path string + srv := fakeAgentVaultRevokeServer(t, &auth, &path) + useInfisicalURL(t, srv.URL+"/api") + useAgentVaultDomainExplicitlySet(t, true) + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + + if _, err := revokeAgentVaultSessionByToken(ctx, resty.New(), "agv_x"); err == nil { + t.Fatal("a revoke past its deadline should fail") + } + if err := api.CallRevokeAgentVaultSession(ctx, resty.New(), "s1"); err == nil { + t.Fatal("a revoke by ID past its deadline should fail") + } + if auth != "" { + t.Fatal("nothing should be sent past the deadline") + } + if messages := agentVaultRevokeErrorMessages(ctx, errors.New("timeout"), true); len(messages) != 1 || !strings.Contains(messages[0], "did not answer within 30s") { + t.Fatalf("messages %v", messages) + } +} + +func TestAgentVaultRevokeErrorMessages(t *testing.T) { + unauthorized := &api.APIError{StatusCode: http.StatusUnauthorized} + if messages := agentVaultRevokeErrorMessages(context.Background(), unauthorized, true); !strings.Contains(messages[0], "does not recognize this session token") { + t.Fatalf("by token: %v", messages) + } + if messages := agentVaultRevokeErrorMessages(context.Background(), unauthorized, false); messages[0] != "Unable to revoke the session" { + t.Fatalf("by ID: %v", messages) + } +} + +func TestRefuseAgentVaultMintOnlyFlagsRefusesAProjectWithASessionToken(t *testing.T) { + newCmd := func(args ...string) *cobra.Command { + cmd := &cobra.Command{Use: "run", Run: func(*cobra.Command, []string) {}} + cmd.Flags().String("ttl", "never", "") + cmd.Flags().Bool("keep-session", false, "") + cmd.Flags().String("project-id", "", "") + if err := cmd.Flags().Parse(args); err != nil { + t.Fatal(err) + } + return cmd + } + + err := refuseAgentVaultMintOnlyFlags(newCmd("--project-id", projectFromFlag), "agv_x", agentVaultSourceEnv) + if err == nil || !strings.Contains(err.Error(), "--project-id") || !strings.Contains(err.Error(), agentVaultSessionTokenEnv) { + t.Fatalf("err %v", err) + } + if err := refuseAgentVaultMintOnlyFlags(newCmd("--project-id", projectFromFlag), "", agentVaultSourceNone); err != nil { + t.Fatalf("minting takes --project-id: %v", err) + } + // A project in the environment is ambient, so a run on a dashboard session just ignores it. + t.Setenv(agentVaultProjectIDEnv, projectFromAgvEnv) + if err := refuseAgentVaultMintOnlyFlags(newCmd(), "agv_x", agentVaultSourceFlag); err != nil { + t.Fatalf("err %v", err) + } +} diff --git a/packages/cmd/agent_vault_skills.go b/packages/cmd/agent_vault_skills.go index 984b70f2..a7ddde57 100644 --- a/packages/cmd/agent_vault_skills.go +++ b/packages/cmd/agent_vault_skills.go @@ -28,7 +28,10 @@ var agentVaultSkillsPushCmd = &cobra.Command{ Long: `Create a skill from a folder, or publish the folder as its next version. The folder holds one skill, with SKILL.md at its root. The name in the SKILL.md header picks the skill: a new -name creates it, and an existing one gets a new version unless nothing changed.`, +name creates it, and an existing one gets a new version unless nothing changed. + +The skill belongs to the Agent Vault project from --project-id, or else INFISICAL_AGENT_VAULT_PROJECT_ID or +INFISICAL_PROJECT_ID, in that order.`, Example: " infisical agent-vault skills push ./skills/code-review", DisableFlagsInUseLine: true, Args: cobra.ExactArgs(1), @@ -37,6 +40,11 @@ name creates it, and an existing one gets a new version unless nothing changed.` if err != nil { util.HandleError(err) } + project, err := resolveAgentVaultProject(cmd) + if err != nil { + util.HandleError(err) + } + echoAgentVaultProject(cmd, project) identity := resolveAgentVaultIdentityToken(cmd) httpClient, err := util.GetRestyClientWithCustomHeaders() @@ -45,19 +53,19 @@ name creates it, and an existing one gets a new version unless nothing changed.` } httpClient.SetAuthToken(identity) - result, err := pushAgentVaultSkill(httpClient, name, api.AgentVaultSkillFilesRequest{Files: files}) + result, err := pushAgentVaultSkill(httpClient, project.ID, name, files) if err != nil { - util.HandleError(err) + util.HandleError(err, agentVaultProjectHints(err, project)...) } fmt.Println(result) }, } -// pushAgentVaultSkill publishes a version of the skill with this name, or creates it. A create that loses a +// pushAgentVaultSkill publishes a version of the skill with this name in the project, or creates it there. A create that loses a // race with another push of the same name gets 409, and publishes to the skill the other push made. -func pushAgentVaultSkill(httpClient *resty.Client, name string, request api.AgentVaultSkillFilesRequest) (string, error) { +func pushAgentVaultSkill(httpClient *resty.Client, projectID, name string, files []api.AgentVaultSkillFile) (string, error) { publish := func(skillID string) (string, error) { - published, err := api.CallUpdateAgentVaultSkill(httpClient, skillID, request) + published, err := api.CallUpdateAgentVaultSkill(httpClient, skillID, api.AgentVaultSkillFilesRequest{Files: files}) if err != nil { return "", fmt.Errorf("unable to publish a new version of %q: %w", name, err) } @@ -67,7 +75,7 @@ func pushAgentVaultSkill(httpClient *resty.Client, name string, request api.Agen return fmt.Sprintf("%s: v%d", name, published.Version.Version), nil } find := func() (string, error) { - listed, err := api.CallListAgentVaultSkillsByName(httpClient, name) + listed, err := api.CallListAgentVaultSkillsByName(httpClient, projectID, name) if err != nil { return "", fmt.Errorf("unable to look up the skill %q: %w", name, err) } @@ -87,7 +95,7 @@ func pushAgentVaultSkill(httpClient *resty.Client, name string, request api.Agen return publish(skillID) } - created, err := api.CallCreateAgentVaultSkill(httpClient, request) + created, err := api.CallCreateAgentVaultSkill(httpClient, api.CreateAgentVaultSkillRequest{ProjectID: projectID, Files: files}) var apiErr *api.APIError if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusConflict { if existing, findErr := find(); findErr == nil && existing != "" { @@ -173,6 +181,7 @@ func readAgentVaultSkillFolder(dir string) ([]api.AgentVaultSkillFile, string, e } func init() { + agentVaultSkillsPushCmd.Flags().String("project-id", "", "Agent Vault project the skill belongs to (falls back to "+agentVaultProjectIDEnv+", then INFISICAL_PROJECT_ID)") agentVaultSkillsPushCmd.Flags().String("client-id", "", "universal auth client id of the machine identity to push as (falls back to INFISICAL_UNIVERSAL_AUTH_CLIENT_ID)") agentVaultSkillsPushCmd.Flags().String("client-secret", "", "universal auth client secret of that machine identity (falls back to INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET)") diff --git a/packages/cmd/agent_vault_skills_test.go b/packages/cmd/agent_vault_skills_test.go index 2bdc5034..06874eb8 100644 --- a/packages/cmd/agent_vault_skills_test.go +++ b/packages/cmd/agent_vault_skills_test.go @@ -119,15 +119,21 @@ func newFakeSkillsAPI(t *testing.T, f *fakeSkillsAPI) *resty.Client { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() - body, _ := io.ReadAll(r.Body) + raw, _ := io.ReadAll(r.Body) + var sent map[string]json.RawMessage + _ = json.Unmarshal(raw, &sent) + // What a version holds is its files, so that is what decides whether a publish changed anything. + body := string(sent["files"]) w.Header().Set("Content-Type", "application/json") path := strings.TrimPrefix(r.URL.Path, "/api/v1/agent-vault") switch { case r.Method == http.MethodGet && path == "/skills": - f.calls = append(f.calls, "find "+r.URL.Query().Get("name")) + f.calls = append(f.calls, "find "+r.URL.Query().Get("projectId")+" "+r.URL.Query().Get("name")) _ = json.NewEncoder(w).Encode(map[string]any{"skills": f.existing, "totalCount": len(f.existing)}) case r.Method == http.MethodPost && path == "/skills": - f.calls = append(f.calls, "create") + var projectID string + _ = json.Unmarshal(sent["projectId"], &projectID) + f.calls = append(f.calls, "create "+projectID) if f.createStatus != 0 { f.existing = f.existingAfterConflict w.WriteHeader(f.createStatus) @@ -135,15 +141,19 @@ func newFakeSkillsAPI(t *testing.T, f *fakeSkillsAPI) *resty.Client { return } f.existing = []api.AgentVaultSkill{{ID: "new", Name: "code-review"}} - f.lastBody, f.lastVersion = string(body), 1 + f.lastBody, f.lastVersion = body, 1 _ = json.NewEncoder(w).Encode(map[string]any{"skill": map[string]any{"id": "new", "name": "code-review"}, "version": map[string]any{"version": 1}}) case r.Method == http.MethodPatch && strings.Count(path, "/") == 2: - f.calls = append(f.calls, "update "+strings.Split(path, "/")[2]) - if f.lastBody == string(body) { + call := "update " + strings.Split(path, "/")[2] + if _, ok := sent["projectId"]; ok { + call += " with a projectId" + } + f.calls = append(f.calls, call) + if f.lastBody == body { _ = json.NewEncoder(w).Encode(map[string]any{"version": map[string]any{"version": f.lastVersion}, "unchanged": true}) return } - f.lastBody, f.lastVersion = string(body), 4 + f.lastBody, f.lastVersion = body, 4 _ = json.NewEncoder(w).Encode(map[string]any{"version": map[string]any{"version": 4}, "unchanged": false}) default: http.NotFound(w, r) @@ -165,18 +175,18 @@ func TestPushAgentVaultSkill(t *testing.T) { wantOut string wantErr bool }{ - {"new name creates", &fakeSkillsAPI{}, "find code-review,create", "code-review: created v1", false}, - {"known name publishes", &fakeSkillsAPI{existing: found}, "find code-review,update sk1", "code-review: v4", false}, + {"new name creates", &fakeSkillsAPI{}, "find p1 code-review,create p1", "code-review: created v1", false}, + {"known name publishes", &fakeSkillsAPI{existing: found}, "find p1 code-review,update sk1", "code-review: v4", false}, {"lost create race publishes", &fakeSkillsAPI{createStatus: http.StatusConflict, existingAfterConflict: found}, - "find code-review,create,find code-review,update sk1", "code-review: v4", false}, + "find p1 code-review,create p1,find p1 code-review,update sk1", "code-review: v4", false}, {"409 with nothing to find fails", &fakeSkillsAPI{createStatus: http.StatusConflict}, - "find code-review,create,find code-review", "", true}, + "find p1 code-review,create p1,find p1 code-review", "", true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { f := tc.api client := newFakeSkillsAPI(t, f) - out, err := pushAgentVaultSkill(client, "code-review", api.AgentVaultSkillFilesRequest{}) + out, err := pushAgentVaultSkill(client, "p1", "code-review", nil) if (err != nil) != tc.wantErr || out != tc.wantOut { t.Fatalf("out %q, err %v", out, err) } @@ -201,7 +211,7 @@ func TestPushAgentVaultSkillTwiceIsUnchanged(t *testing.T) { if err != nil { t.Fatal(err) } - out, err := pushAgentVaultSkill(client, name, api.AgentVaultSkillFilesRequest{Files: files}) + out, err := pushAgentVaultSkill(client, "p1", name, files) if err != nil { t.Fatal(err) } @@ -210,7 +220,7 @@ func TestPushAgentVaultSkillTwiceIsUnchanged(t *testing.T) { if got := strings.Join(outs, "|"); got != "code-review: created v1|code-review: unchanged (v1)" { t.Fatalf("outputs %s", got) } - if got := strings.Join(f.calls, ","); got != "find code-review,create,find code-review,update new" { + if got := strings.Join(f.calls, ","); got != "find p1 code-review,create p1,find p1 code-review,update new" { t.Fatalf("calls %s", got) } } diff --git a/packages/cmd/profile.go b/packages/cmd/profile.go index 5ab303e0..5930ae62 100644 --- a/packages/cmd/profile.go +++ b/packages/cmd/profile.go @@ -238,6 +238,7 @@ var profileCurrentCmd = &cobra.Command{ } util.PrintlnStdout("Domain:", util.DisplayDomain(profile.Domain)) printSecretsManagerDefaults() + printAgentVaultProjectForProfile() Telemetry.CaptureEvent("cli-command:profile current", posthog.NewProperties().Set("version", util.CLI_VERSION)) }, diff --git a/packages/cmd/root.go b/packages/cmd/root.go index afc2ede7..460ebe29 100644 --- a/packages/cmd/root.go +++ b/packages/cmd/root.go @@ -110,29 +110,75 @@ func Execute() { // resolveDomain picks the domain by precedence: --domain flag > env > .infisical.yaml > default (flagValue). // Must run after flag parsing (PersistentPreRun, not init) so cmd.Flags().Changed is reliable. -func resolveDomain(cmd *cobra.Command, flagValue string) string { +func resolveDomain(cmd *cobra.Command, flagValue string) (string, error) { if cmd.Flags().Changed("domain") { - return flagValue + return flagValue, nil } if envDomain, ok := util.GetEnvDomain(); ok { - return envDomain + return envDomain, nil } domain, valid := util.GetDomainFromFile() + + // Agent Vault commands send session tokens and mint sessions, and a .infisical.yaml can arrive with a cloned + // repo, so they skip it. The domain comes from the flag, the variables, or the logged-in profile, which is returned + // here so paths that never load the login (proxy tokens, client secrets) use it too. A file that names another instance stops the command instead, so a user who relied on it is not + // sent to the profile's instance without knowing. + if isAgentVaultCommand(cmd) { + effectiveDomain := util.ActiveProfileDomain() + if effectiveDomain == "" { + effectiveDomain = flagValue + } + if domain != "" && valid && util.AppendAPIEndpoint(effectiveDomain) != util.AppendAPIEndpoint(domain) && !agentVaultRunNeverContactsInfisical(cmd) { + return "", fmt.Errorf(".infisical.yaml names a server that Agent Vault commands don't read. Pass --domain or set INFISICAL_DOMAIN.") + } + return effectiveDomain, nil + } + if domain == "" { - return flagValue + return flagValue, nil } if !valid { util.PrintWarningWithWriter("The 'general.domain' field in .infisical.yaml (or 'domain' in a legacy .infisical.json) is not a valid URL (must be an http:// or https:// URL with a host). It will be ignored.", cmd.ErrOrStderr()) - return flagValue + return flagValue, nil } // A .infisical.yaml is usually committed to the repo, so a malicious one could redirect requests // and credentials. Always surface where traffic is going (even under --silent); it goes to stderr. util.PrintWarningWithWriter(fmt.Sprintf("Using domain '%s' from .infisical.yaml (or a legacy .infisical.json); all requests and credentials will be sent there.", domain), cmd.ErrOrStderr()) - return domain + return domain, nil +} + +// A run on a session token with --no-skills mints nothing and installs no skills, so no domain is used and a +// repo file naming another server can't misdirect anything. +func agentVaultRunNeverContactsInfisical(cmd *cobra.Command) bool { + if cmd != agentVaultRunCmd { + return false + } + if noSkills, err := cmd.Flags().GetBool("no-skills"); err != nil || !noSkills { + return false + } + _, tokenSource, err := resolveAgentVaultSessionToken(cmd) + if err != nil { + return false + } + _, bundleSource, err := resolveAgentVaultAccessBundles(cmd) + if err != nil { + return false + } + useToken, _, err := decideAgentVaultSession(tokenSource, bundleSource) + return err == nil && useToken +} + +func isAgentVaultCommand(cmd *cobra.Command) bool { + for current := cmd; current != nil; current = current.Parent() { + if current == agentVaultCmd { + return true + } + } + return false } // Commands that manage profiles/sessions themselves print their own outcome, @@ -203,7 +249,11 @@ func init() { _, envDomainSet := util.GetEnvDomain() config.INFISICAL_DOMAIN_EXPLICITLY_SET = cmd.Flags().Changed("domain") || envDomainSet - config.INFISICAL_URL = util.AppendAPIEndpoint(resolveDomain(cmd, config.INFISICAL_URL)) + domain, err := resolveDomain(cmd, config.INFISICAL_URL) + if err != nil { + util.HandleError(err) + } + config.INFISICAL_URL = util.AppendAPIEndpoint(domain) if !util.IsRunningInDocker() && !silent && !isStructuredOutputRequested(cmd) { util.CheckForUpdateWithWriter(cmd.ErrOrStderr()) diff --git a/packages/cmd/root_domain_test.go b/packages/cmd/root_domain_test.go new file mode 100644 index 00000000..c8e5dcb0 --- /dev/null +++ b/packages/cmd/root_domain_test.go @@ -0,0 +1,268 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/Infisical/infisical-merge/packages/models" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +func useRepoFileDomain(t *testing.T, domain string) { + t.Helper() + for _, env := range util.DomainEnvNames { + t.Setenv(env, "") + } + repo := t.TempDir() + content := "version: 1\ngeneral:\n domain: " + domain + "\n" + if err := os.WriteFile(filepath.Join(repo, util.INFISICAL_WORKSPACE_CONFIG_FILE_NAME), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + t.Chdir(repo) +} + +func useProfileDomain(t *testing.T, domain string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("the home folder is not HOME here") + } + home := t.TempDir() + t.Setenv("HOME", home) + dir := filepath.Join(home, util.CONFIG_FOLDER_NAME) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + content, err := json.Marshal(models.ConfigFile{LoggedInUserDomain: domain}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, util.CONFIG_FILE_NAME), content, 0o600); err != nil { + t.Fatal(err) + } +} + +var agentVaultDomainTestCommands = []*cobra.Command{agentVaultCmd, agentVaultProxyCmd, agentVaultSessionsRevokeCmd} + +func resolveAgentVaultDomain(t *testing.T, cmd *cobra.Command, flagValue string) (string, error) { + t.Helper() + var stderr bytes.Buffer + cmd.SetErr(&stderr) + defer cmd.SetErr(nil) + got, err := resolveDomain(cmd, flagValue) + if stderr.Len() != 0 { + t.Fatalf("%s: warned %q", cmd.CommandPath(), stderr.String()) + } + return got, err +} + +func TestResolveDomainStopsAgentVaultCommandsWhenTheRepoFileNamesAnotherInstance(t *testing.T) { + useProfileDomain(t, "https://app.infisical.com/api") + useRepoFileDomain(t, "https://eu.infisical.com") + const want = ".infisical.yaml names a server that Agent Vault commands don't read. Pass --domain or set INFISICAL_DOMAIN." + + for _, cmd := range agentVaultDomainTestCommands { + if _, err := resolveAgentVaultDomain(t, cmd, "https://app.infisical.com/api"); err == nil || err.Error() != want { + t.Fatalf("%s: got error %v, want %q", cmd.CommandPath(), err, want) + } + } +} + +// The file arrives with a cloned repo, so its value is never echoed into a message a user may copy from. +func TestResolveDomainNeverRepeatsTheRepoFileDomainForAgentVaultCommands(t *testing.T) { + useProfileDomain(t, "https://app.infisical.com/api") + const malicious = "https://evil.example/$(curl x)" + useRepoFileDomain(t, "'"+malicious+"'") + if domain, valid := util.GetDomainFromFile(); domain != malicious || !valid { + t.Fatalf("the file domain should read back as a valid %q, got %q (valid %v)", malicious, domain, valid) + } + + for _, cmd := range agentVaultDomainTestCommands { + _, err := resolveAgentVaultDomain(t, cmd, "https://app.infisical.com/api") + if err == nil || strings.Contains(err.Error(), "evil.example") || strings.Contains(err.Error(), "curl") { + t.Fatalf("%s: got error %v", cmd.CommandPath(), err) + } + } +} + +// setAgentVaultRunFlags sets flags on the real run command, which resolveDomain recognizes by identity, and +// resets them when the test ends. +func setAgentVaultRunFlags(t *testing.T, values map[string]string) { + t.Helper() + t.Cleanup(func() { + for _, name := range []string{"session-token", "no-skills", "access-bundle"} { + flag := agentVaultRunCmd.Flags().Lookup(name) + if slice, ok := flag.Value.(pflag.SliceValue); ok { + _ = slice.Replace(nil) + } else { + _ = flag.Value.Set(flag.DefValue) + } + flag.Changed = false + } + }) + for name, value := range values { + if err := agentVaultRunCmd.Flags().Set(name, value); err != nil { + t.Fatal(err) + } + } +} + +func TestResolveDomainLetsAgentVaultRunOnASessionTokenWithoutSkillsIgnoreTheRepoFile(t *testing.T) { + cases := []struct { + name string + flags map[string]string + tokenEnv string + bundleEnv string + }{ + {name: "token flag", flags: map[string]string{"session-token": "agv_x", "no-skills": "true"}}, + {name: "token variable", flags: map[string]string{"no-skills": "true"}, tokenEnv: "agv_x"}, + {name: "token flag beats a bundle variable", flags: map[string]string{"session-token": "agv_x", "no-skills": "true"}, bundleEnv: "coding-agent"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + useProfileDomain(t, "https://app.infisical.com/api") + useRepoFileDomain(t, "https://eu.infisical.com") + t.Setenv(agentVaultSessionTokenEnv, c.tokenEnv) + t.Setenv(agentVaultAccessBundleEnv, c.bundleEnv) + setAgentVaultRunFlags(t, c.flags) + + got, err := resolveAgentVaultDomain(t, agentVaultRunCmd, "https://app.infisical.com/api") + if err != nil || got != "https://app.infisical.com/api" { + t.Fatalf("resolved %s, %v", got, err) + } + }) + } +} + +func TestResolveDomainStillStopsAgentVaultRunWhenItWouldContactInfisical(t *testing.T) { + cases := []struct { + name string + flags map[string]string + tokenEnv string + bundleEnv string + }{ + {name: "token without --no-skills", flags: map[string]string{"session-token": "agv_x"}}, + {name: "--no-skills without a token", flags: map[string]string{"no-skills": "true"}}, + {name: "--no-skills minting over a bundle", flags: map[string]string{"access-bundle": "coding-agent", "no-skills": "true"}, tokenEnv: "agv_x"}, + {name: "--no-skills with both variables", flags: map[string]string{"no-skills": "true"}, tokenEnv: "agv_x", bundleEnv: "coding-agent"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + useProfileDomain(t, "https://app.infisical.com/api") + useRepoFileDomain(t, "https://eu.infisical.com") + t.Setenv(agentVaultSessionTokenEnv, c.tokenEnv) + t.Setenv(agentVaultAccessBundleEnv, c.bundleEnv) + setAgentVaultRunFlags(t, c.flags) + + if _, err := resolveAgentVaultDomain(t, agentVaultRunCmd, "https://app.infisical.com/api"); err == nil { + t.Fatal("want the run stopped") + } + }) + } +} + +func TestResolveDomainLetsTheFlagWinOverTheRepoFileForAgentVaultCommands(t *testing.T) { + useProfileDomain(t, "https://app.infisical.com/api") + useRepoFileDomain(t, "https://eu.infisical.com") + + cmd := &cobra.Command{Use: "proxy"} + cmd.Flags().String("domain", "", "") + agentVaultCmd.AddCommand(cmd) + t.Cleanup(func() { agentVaultCmd.RemoveCommand(cmd) }) + if err := cmd.Flags().Set("domain", "https://self-hosted.example.com"); err != nil { + t.Fatal(err) + } + + got, err := resolveAgentVaultDomain(t, cmd, "https://self-hosted.example.com") + if err != nil || got != "https://self-hosted.example.com" { + t.Fatalf("resolved %s, %v", got, err) + } +} + +func TestResolveDomainLetsTheVariableWinOverTheRepoFileForAgentVaultCommands(t *testing.T) { + useProfileDomain(t, "https://app.infisical.com/api") + useRepoFileDomain(t, "https://eu.infisical.com") + t.Setenv(util.INFISICAL_DOMAIN_ENV_NAME, "https://self-hosted.example.com") + + for _, cmd := range agentVaultDomainTestCommands { + got, err := resolveAgentVaultDomain(t, cmd, "https://app.infisical.com/api") + if err != nil || got != "https://self-hosted.example.com" { + t.Fatalf("%s: resolved %s, %v", cmd.CommandPath(), got, err) + } + } +} + +func TestResolveDomainAllowsAgentVaultCommandsWhenTheRepoFileMatchesTheProfile(t *testing.T) { + useProfileDomain(t, "https://eu.infisical.com/api") + useRepoFileDomain(t, "https://eu.infisical.com/") + const fallback = "https://app.infisical.com/api" + + for _, cmd := range agentVaultDomainTestCommands { + got, err := resolveAgentVaultDomain(t, cmd, fallback) + if err != nil || got != "https://eu.infisical.com/api" { + t.Fatalf("%s: resolved %s, %v", cmd.CommandPath(), got, err) + } + } +} + +func TestResolveDomainComparesTheRepoFileWithTheDefaultWhenNoOneIsLoggedIn(t *testing.T) { + useProfileDomain(t, "") + useRepoFileDomain(t, "https://app.infisical.com") + const fallback = "https://app.infisical.com/api" + + for _, cmd := range agentVaultDomainTestCommands { + got, err := resolveAgentVaultDomain(t, cmd, fallback) + if err != nil || got != fallback { + t.Fatalf("%s: resolved %s, %v", cmd.CommandPath(), got, err) + } + } +} + +func TestResolveDomainIgnoresAnInvalidRepoFileDomainForAgentVaultCommands(t *testing.T) { + useProfileDomain(t, "https://app.infisical.com/api") + useRepoFileDomain(t, "not-a-url") + const fallback = "https://app.infisical.com/api" + + for _, cmd := range agentVaultDomainTestCommands { + got, err := resolveAgentVaultDomain(t, cmd, fallback) + if err != nil || got != fallback { + t.Fatalf("%s: resolved %s, %v", cmd.CommandPath(), got, err) + } + } +} + +func TestResolveDomainUsesTheProfileForAgentVaultCommandsWithoutARepoFile(t *testing.T) { + useProfileDomain(t, "https://eu.infisical.com/api") + for _, env := range util.DomainEnvNames { + t.Setenv(env, "") + } + t.Chdir(t.TempDir()) + const fallback = "https://app.infisical.com/api" + + for _, cmd := range agentVaultDomainTestCommands { + got, err := resolveAgentVaultDomain(t, cmd, fallback) + if err != nil || got != "https://eu.infisical.com/api" { + t.Fatalf("%s: resolved %s, %v", cmd.CommandPath(), got, err) + } + } +} + +func TestResolveDomainStillReadsTheRepoFileForOtherCommands(t *testing.T) { + useRepoFileDomain(t, "https://eu.infisical.com") + + var stderr bytes.Buffer + cmd := &cobra.Command{Use: "secrets"} + cmd.SetErr(&stderr) + if got, err := resolveDomain(cmd, "https://app.infisical.com/api"); err != nil || got != "https://eu.infisical.com" { + t.Fatalf("resolved %s, %v", got, err) + } + if !strings.Contains(stderr.String(), "Using domain 'https://eu.infisical.com' from .infisical.yaml") { + t.Fatalf("warned %q", stderr.String()) + } +} diff --git a/packages/cmd/secrets_manager_errors.go b/packages/cmd/secrets_manager_errors.go index 932b48a1..6c468fd1 100644 --- a/packages/cmd/secrets_manager_errors.go +++ b/packages/cmd/secrets_manager_errors.go @@ -48,6 +48,7 @@ var errorCodes = []string{ errCodeEnvironmentExists, errCodeEnvironmentPending, errCodeProjectRequired, errCodeInvalidFlagValue, errCodeProjectDeleteProtected, errCodeInvalidArgument, errCodeServiceTokenUnsupported, errCodeProjectConfigInvalid, errCodeTokenProjectMismatch, errCodeFolderExists, + errCodeAccessBundleRequired, errCodeInvalidAccessBundle, errCodeInvalidProjectID, errCodeAccessBundleNotGranted, } const secretsManagerPrefix = "infisical " + secretsManagerCommandName diff --git a/packages/cmd/secrets_manager_errors_test.go b/packages/cmd/secrets_manager_errors_test.go index 01319373..5535ed57 100644 --- a/packages/cmd/secrets_manager_errors_test.go +++ b/packages/cmd/secrets_manager_errors_test.go @@ -27,7 +27,7 @@ var notYetShippedCommands = []string{} func everyConstructedError() []*util.CLIError { cause := errors.New("cause") action := destructiveAction{Target: `environment "qa"`, Expected: "qa", Label: "the environment slug", Description: "environment qa"} - return []*util.CLIError{ + return append([]*util.CLIError{ errAuthRequired(cause), errPermissionDenied("You are not allowed to create on environments", cause), errProjectNotLinked(), @@ -62,7 +62,7 @@ func everyConstructedError() []*util.CLIError { errNothingToUpdate([]string{"name", "slug"}), errConfirmationRequired(action, "infisical secrets-manager environments delete qa --yes"), errConfirmationDeclined(action), - } + }, agentVaultConstructedErrors()...) } func TestErrorCodesAreStableSnakeCaseAndUnique(t *testing.T) { diff --git a/packages/cmd/testdata/command_surface.json b/packages/cmd/testdata/command_surface.json index 4fad93ed..8e9ee39c 100644 --- a/packages/cmd/testdata/command_surface.json +++ b/packages/cmd/testdata/command_surface.json @@ -50,15 +50,32 @@ "no-ca-trust": "|bool|false", "no-proxy": "|string|", "no-skills": "|bool|false", + "project-id": "|string|", "proxy": "|string|", "session-token": "|string|", "skills-dir": "|string|", "ttl": "|string|never" }, + "infisical agent-vault sessions": {}, + "infisical agent-vault sessions create": { + "access-bundle": "|stringArray|[]", + "client-id": "|string|", + "client-secret": "|string|", + "label": "|string|", + "output": "o|string|", + "project-id": "|string|", + "ttl": "|string|never" + }, + "infisical agent-vault sessions revoke": { + "client-id": "|string|", + "client-secret": "|string|", + "session-token": "|string|" + }, "infisical agent-vault skills": {}, "infisical agent-vault skills push": { "client-id": "|string|", - "client-secret": "|string|" + "client-secret": "|string|", + "project-id": "|string|" }, "infisical av": {}, "infisical av proxy": { @@ -88,15 +105,32 @@ "no-ca-trust": "|bool|false", "no-proxy": "|string|", "no-skills": "|bool|false", + "project-id": "|string|", "proxy": "|string|", "session-token": "|string|", "skills-dir": "|string|", "ttl": "|string|never" }, + "infisical av sessions": {}, + "infisical av sessions create": { + "access-bundle": "|stringArray|[]", + "client-id": "|string|", + "client-secret": "|string|", + "label": "|string|", + "output": "o|string|", + "project-id": "|string|", + "ttl": "|string|never" + }, + "infisical av sessions revoke": { + "client-id": "|string|", + "client-secret": "|string|", + "session-token": "|string|" + }, "infisical av skills": {}, "infisical av skills push": { "client-id": "|string|", - "client-secret": "|string|" + "client-secret": "|string|", + "project-id": "|string|" }, "infisical bootstrap": { "domain": "|string|", diff --git a/packages/util/cli_error.go b/packages/util/cli_error.go index 50aa008e..5b545525 100644 --- a/packages/util/cli_error.go +++ b/packages/util/cli_error.go @@ -44,7 +44,7 @@ var ( credentialLooking = regexp.MustCompile(`\b(?:st\.[A-Za-z0-9._-]+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9._-]+)`) // Third-party token formats that would otherwise pass as a secret name or slug. Each is matched by // its prefix and length, so names that only start the same way (npm_config_registry) still print. - knownTokenFormat = regexp.MustCompile(`\b(?:gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{22,}|[rs]k_(?:live|test)_[A-Za-z0-9]{10,}|xox[abprs]-[A-Za-z0-9-]{10,}|glpat-[A-Za-z0-9_-]{20,}|npm_[A-Za-z0-9]{36}|(?:AKIA|ASIA)[0-9A-Z]{16}|sk-[A-Za-z0-9_-]{20,})\b`) + knownTokenFormat = regexp.MustCompile(`\b(?:gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{22,}|[rs]k_(?:live|test)_[A-Za-z0-9]{10,}|xox[abprs]-[A-Za-z0-9-]{10,}|glpat-[A-Za-z0-9_-]{20,}|npm_[A-Za-z0-9]{36}|(?:AKIA|ASIA)[0-9A-Z]{16}|sk-[A-Za-z0-9_-]{20,}|agv_[A-Za-z0-9_-]{43})\b`) ) func isKnownToken(match string) bool {