diff --git a/cmd/volcano/grant_test.go b/cmd/volcano/grant_test.go index 335a3a5e..67d93812 100644 --- a/cmd/volcano/grant_test.go +++ b/cmd/volcano/grant_test.go @@ -170,3 +170,24 @@ func TestGrantIsRefusedOnAccountCommandsBeforeAnyRequest(t *testing.T) { }) } } + +// testFunctionID is the function the invoke tests call. +const testFunctionID = "99999999-9999-4999-8999-999999999999" + +// Invoke sends the grant itself to the data-plane invoke route. It never +// lists, reads or creates the CLI's service key (runAsWorker fails any +// /service-keys request). Hosting accepts a grant there from C6 on. +func TestGrantInvokesWithTheGrantAndNoServiceKey(t *testing.T) { + run := runAsWorker(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/functions/"+testFunctionID+"/invoke" { + respondExitTestJSON(w, http.StatusOK, `{"ok":true}`) + return + } + respondExitTestJSON(w, http.StatusForbidden, `{"error":"delegated grants cannot be used on this endpoint","code":"grant_route_not_allowed"}`) + }, "cloud", "functions", "invoke", "--id", testFunctionID, "--json") + + assert.Equal(t, 0, run.code, run.stderr) + assert.Equal(t, []string{"POST /functions/" + testFunctionID + "/invoke"}, run.requests) + assert.Equal(t, []string{"Bearer " + testGrant}, run.auth) + assert.JSONEq(t, `{"ok":true}`, run.stdout) +} diff --git a/internal/cmd/cloud/cloud.go b/internal/cmd/cloud/cloud.go index 6ec304fd..51d7fe4d 100644 --- a/internal/cmd/cloud/cloud.go +++ b/internal/cmd/cloud/cloud.go @@ -65,7 +65,7 @@ func NewResourceCommands(deps cliruntime.Deps) []*cobra.Command { domainscmd.New(deps), durablecmd.New(deps), frontendscmd.New(deps), - functionscmd.NewWithOptions(deps, functionscmd.WithInvokeTokenProvider(dataPlaneKeys.ServiceKeyForProject)), + functionscmd.NewWithOptions(deps, functionscmd.WithInvokeTokenProvider(dataPlaneKeys.InvokeToken)), lavaflowcmd.New(deps), logscmd.New(deps), notificationscmd.New(deps), diff --git a/internal/dataplane/service_key.go b/internal/dataplane/service_key.go index dae659ef..3e3bc6cb 100644 --- a/internal/dataplane/service_key.go +++ b/internal/dataplane/service_key.go @@ -10,6 +10,7 @@ import ( "github.com/Kong/volcano-cli/internal/api" "github.com/Kong/volcano-cli/internal/apiclient" + "github.com/Kong/volcano-cli/internal/config" cliruntime "github.com/Kong/volcano-cli/internal/runtime" clisession "github.com/Kong/volcano-cli/internal/session" ) @@ -50,6 +51,16 @@ func NewService(deps cliruntime.Deps) Service { } } +// InvokeToken returns the bearer for the function invoke route. A pg- +// delegated grant invokes with itself, as a service key does; any other +// credential gets the reserved service key, as before. +func (s Service) InvokeToken(ctx context.Context, project *clisession.ProjectSession) (string, error) { + if project != nil && project.Config != nil && config.IsDelegatedGrant(project.Config.Token()) { + return strings.TrimSpace(project.Config.Token()), nil + } + return s.ServiceKeyForProject(ctx, project) +} + // ServiceKey returns the reserved service key for the current project, creating // it when it does not already exist. func (s Service) ServiceKey(ctx context.Context) (string, error) { diff --git a/internal/dataplane/service_key_test.go b/internal/dataplane/service_key_test.go index 2e86f924..36614fa8 100644 --- a/internal/dataplane/service_key_test.go +++ b/internal/dataplane/service_key_test.go @@ -185,3 +185,52 @@ func serviceKeyPayload(id, name, keyValue string) map[string]any { "updated_at": "2026-05-20T00:00:00Z", } } + +// A delegated grant invokes with itself. Invoke never lists, reads or creates +// the reserved service key under a grant, so a grant never causes a +// long-lived key. +func TestInvokeTokenUsesADelegatedGrantDirectly(t *testing.T) { + setServiceKeyTestHome(t) + saveServiceKeyTestConfig(t) + t.Setenv("VOLCANO_TOKEN", cliconfig.DelegatedGrantPrefix+"grant-secret") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("a grant must not reach the API to get an invoke token: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + })) + defer server.Close() + + service := NewService(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}) + project, err := service.sessions.CurrentProject() + require.NoError(t, err) + + token, err := service.InvokeToken(t.Context(), project) + require.NoError(t, err) + assert.Equal(t, cliconfig.DelegatedGrantPrefix+"grant-secret", token) +} + +// Any other credential still gets the reserved service key for invoke. +func TestInvokeTokenUsesTheServiceKeyForOtherCredentials(t *testing.T) { + setServiceKeyTestHome(t) + saveServiceKeyTestConfig(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer platform-token", r.Header.Get("Authorization")) + if r.Method == http.MethodGet && r.URL.Path == "/projects/"+testProjectID+"/service-keys" { + writeServiceKeyJSON(t, w, http.StatusOK, map[string]any{ + "data": []any{serviceKeyPayload("11111111-1111-4111-8111-111111111111", CLIServiceKeyName, "sk-existing")}, + "has_more": false, "page": 1, "limit": 100, "total": 1, + }) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + service := NewService(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}) + project, err := service.sessions.CurrentProject() + require.NoError(t, err) + token, err := service.InvokeToken(t.Context(), project) + require.NoError(t, err) + assert.Equal(t, "sk-existing", token) +}