From 2c5d20f5e4a3f67f80259ef02fbc6a423b07b771 Mon Sep 17 00:00:00 2001 From: Ted Kim Date: Wed, 11 Nov 2026 22:30:02 -0500 Subject: [PATCH 1/2] feat(functions): invoke with a delegated grant, not a service key --- cmd/volcano/grant_test.go | 34 ++++++++++++++++ docs/authentication.md | 4 ++ internal/cmd/cloud/cloud.go | 2 +- internal/config/config.go | 10 +++-- internal/config/config_test.go | 10 +++++ internal/dataplane/service_key.go | 25 ++++++++++++ internal/dataplane/service_key_test.go | 56 ++++++++++++++++++++++++++ 7 files changed, 137 insertions(+), 4 deletions(-) diff --git a/cmd/volcano/grant_test.go b/cmd/volcano/grant_test.go index 262c6704..3e0aa71a 100644 --- a/cmd/volcano/grant_test.go +++ b/cmd/volcano/grant_test.go @@ -191,3 +191,37 @@ func TestGrantMismatchHintNamesTheGrant(t *testing.T) { assert.Equal(t, exitPermissionDenied, run.code, run.stderr) assert.Contains(t, run.stderr, "A delegated grant only works on the project it was granted for") } + +// 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) +} + +// Storage object commands need the CLI's service key, so a grant refuses them +// before any request. +func TestGrantRefusesStorageObjectCommandsBeforeAnyRequest(t *testing.T) { + run := runAsWorker(t, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("a storage object command reached the API: %s %s", r.Method, r.URL.Path) + respondExitTestJSON(w, http.StatusForbidden, `{"error":"forbidden"}`) + }, "cloud", "storage", "object", "list", "uploads") + + assert.Equal(t, exitPermissionDenied, run.code, run.stderr) + assert.Contains(t, run.stderr, "storage object commands do not work with a delegated grant (pg-)") + assert.Empty(t, run.requests) +} diff --git a/docs/authentication.md b/docs/authentication.md index 36b6be73..6c0ef1c6 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -114,6 +114,10 @@ refuses one, because it would outlast the run it was made for. Commands outside the grant fail with exit code `5`, and a grant that has used up its changes fails with exit code `6`. See [scripting.md](scripting.md#exit-codes). +`volcano cloud functions invoke` sends the grant itself and never creates a +service key for it. Storage object commands need a service key, so they don't +work with a grant. + Log out at any time: ```bash 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/config/config.go b/internal/config/config.go index 787a687a..dbefb3cb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -353,10 +353,14 @@ func (c *Config) TokenFromEnv() bool { // FunctionInvokeToken returns the token used for runtime function invocation. // Cloud uses the reserved data-plane service key (via the invoke token -// provider) or falls back to the configured token. In local mode the session -// layer sends no credential at all (see session.Factory.APIClient), so this -// value is unused there. +// provider) or falls back to the configured token. A delegated grant is used +// as is, ahead of any saved key, so a grant's invoke runs as the grant. In +// local mode the session layer sends no credential at all (see +// session.Factory.APIClient), so this value is unused there. func (c *Config) FunctionInvokeToken() string { + if token := c.Token(); IsDelegatedGrant(token) { + return token + } if strings.TrimSpace(c.ServiceKey) != "" { return c.ServiceKey } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 67e8493c..6055ac5e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -548,3 +548,13 @@ func TestRequireAccountTokenAndProjectRefuseADelegatedGrant(t *testing.T) { assert.Contains(t, err.Error(), "delegated grant (pg-)") assert.NotContains(t, err.Error(), "secret-grant") } + +// A grant invokes as the grant, ahead of any key saved in the config. +func TestFunctionInvokeTokenPrefersADelegatedGrant(t *testing.T) { + t.Setenv(envToken, DelegatedGrantPrefix+"grant") + cfg := &Config{ServiceKey: "sk-saved", AnonKey: "anon-saved"} + assert.Equal(t, DelegatedGrantPrefix+"grant", cfg.FunctionInvokeToken()) + + t.Setenv(envToken, AccountTokenPrefix+"account") + assert.Equal(t, "sk-saved", cfg.FunctionInvokeToken()) +} diff --git a/internal/dataplane/service_key.go b/internal/dataplane/service_key.go index dae659ef..80edd26d 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,27 @@ func NewService(deps cliruntime.Deps) Service { } } +// ErrDelegatedGrantNoServiceKey refuses to resolve the reserved service key +// under a pg- delegated grant. A grant must never cause a long-lived key to be +// listed, read or minted: the key would outlive the run the grant was minted +// for. Storage object commands need that key, so they don't work with a grant. +// It matches config.ErrAccountTokenRequired, so the exit code reports a refused +// credential. +var ErrDelegatedGrantNoServiceKey = api.WithCause(errors.New( + "storage object commands do not work with a delegated grant ("+config.DelegatedGrantPrefix+ + "): they need a project service key, which a grant never creates or reads"), + config.ErrAccountTokenRequired) + +// 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) { @@ -73,6 +95,9 @@ func (s Service) ServiceKeyForProject(ctx context.Context, project *clisession.P if token := sessionServiceKey(project); token != "" { return token, nil } + if project.Config != nil && config.IsDelegatedGrant(project.Config.Token()) { + return "", ErrDelegatedGrantNoServiceKey + } name := s.serviceKeyName() key, found, err := s.findServiceKey(ctx, project, name) if err != nil { diff --git a/internal/dataplane/service_key_test.go b/internal/dataplane/service_key_test.go index 2e86f924..f4c49689 100644 --- a/internal/dataplane/service_key_test.go +++ b/internal/dataplane/service_key_test.go @@ -185,3 +185,59 @@ func serviceKeyPayload(id, name, keyValue string) map[string]any { "updated_at": "2026-05-20T00:00:00Z", } } + +// A delegated grant invokes with itself. It never lists, reads or creates the +// reserved service key, 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) + + _, err = service.ServiceKeyForProject(t.Context(), project) + require.ErrorIs(t, err, ErrDelegatedGrantNoServiceKey) + require.ErrorIs(t, err, cliconfig.ErrAccountTokenRequired) + assert.NotContains(t, err.Error(), "grant-secret") + + _, err = service.ServiceKey(t.Context()) + require.ErrorIs(t, err, ErrDelegatedGrantNoServiceKey) +} + +// 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) +} From b0b2dbc17f860a83279c494731d077b79d679988 Mon Sep 17 00:00:00 2001 From: Ted Kim Date: Wed, 11 Nov 2026 23:29:48 -0500 Subject: [PATCH 2/2] style(functions): format the invoke token test --- internal/dataplane/service_key_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/dataplane/service_key_test.go b/internal/dataplane/service_key_test.go index 830a4611..36614fa8 100644 --- a/internal/dataplane/service_key_test.go +++ b/internal/dataplane/service_key_test.go @@ -207,7 +207,6 @@ func TestInvokeTokenUsesADelegatedGrantDirectly(t *testing.T) { 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.