Repository navigation
feat(auth): treat pg- delegated grants as project-bound #476
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7aef76b
0e5ee47
5ec9d35
82f35ea
d1ab0df
b2efdbc
d5f9208
94e843a
ea51c75
726c499
55e9a0e
33e2f79
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,172 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "os" | ||
| "path/filepath" | ||
| "strings" | ||
| "sync" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
|
|
||
| "github.com/Kong/volcano-cli/internal/api" | ||
| rootcmd "github.com/Kong/volcano-cli/internal/cmd/root" | ||
| cliconfig "github.com/Kong/volcano-cli/internal/config" | ||
| cliruntime "github.com/Kong/volcano-cli/internal/runtime" | ||
| ) | ||
|
|
||
| // testGrant is a delegated grant as a worker gets it. Every test here checks | ||
| // that it never shows up in the output. | ||
| const testGrant = cliconfig.DelegatedGrantPrefix + "grant-secret-value" | ||
|
|
||
| // grantRun is one command run the way a Builder worker runs the CLI. | ||
| type grantRun struct { | ||
| code int | ||
| stdout string | ||
| stderr string | ||
| requests []string | ||
| auth []string | ||
| } | ||
|
|
||
| // runAsWorker runs the real command tree with the worker's environment: the | ||
| // grant in VOLCANO_TOKEN, the API in VOLCANO_API_URL, the project from the | ||
| // directory's link file, telemetry off and no prompts. No config loader is | ||
| // injected, so the CLI reads its configuration the way it does in the VM. | ||
| func runAsWorker(t *testing.T, handler http.HandlerFunc, args ...string) grantRun { | ||
| t.Helper() | ||
| resetInstructions(t) | ||
| api.ResetLastRefusalForTest() | ||
| t.Cleanup(api.ResetLastRefusalForTest) | ||
|
|
||
| var mu sync.Mutex | ||
| result := grantRun{} | ||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| mu.Lock() | ||
| result.requests = append(result.requests, r.Method+" "+r.URL.Path) | ||
| result.auth = append(result.auth, r.Header.Get("Authorization")) | ||
| mu.Unlock() | ||
| handler(w, r) | ||
| })) | ||
| t.Cleanup(server.Close) | ||
|
|
||
| home := t.TempDir() | ||
| workspace := filepath.Join(home, "workspace", "app") | ||
| require.NoError(t, os.MkdirAll(filepath.Join(workspace, ".volcano"), 0o755)) | ||
| require.NoError(t, os.WriteFile(filepath.Join(workspace, ".volcano", "project.json"), | ||
| []byte(`{"project_id":"`+mainTestProjectAlpha+`"}`), 0o644)) | ||
| t.Setenv("HOME", home) | ||
| t.Chdir(workspace) | ||
| t.Setenv("VOLCANO_TOKEN", testGrant) | ||
| t.Setenv("VOLCANO_API_URL", server.URL) | ||
| t.Setenv("VOLCANO_PROJECT_ID", "") | ||
| t.Setenv("VOLCANO_TELEMETRY_DISABLED", "1") | ||
| t.Setenv("VOLCANO_NONINTERACTIVE", "1") | ||
|
|
||
| deps := cliruntime.Deps{HTTPClient: server.Client()} | ||
| root := rootcmd.New(deps) | ||
| var stdout, stderr bytes.Buffer | ||
| root.SetOut(&stdout) | ||
| root.SetErr(&stderr) | ||
| root.SetArgs(args) | ||
| result.code = run(root, deps) | ||
| result.stdout, result.stderr = stdout.String(), stderr.String() | ||
|
|
||
| assert.NotContains(t, result.stdout, "grant-secret-value", "the grant was printed") | ||
| assert.NotContains(t, result.stderr, "grant-secret-value", "the grant was printed") | ||
| for _, request := range result.requests { | ||
| assert.NotContains(t, request, "/cli/telemetry", "the grant reached telemetry") | ||
| assert.NotContains(t, request, "/service-keys", "a grant must never cause a service key") | ||
| assert.NotContains(t, request, "/user", "a grant must never reach an account route") | ||
| } | ||
| return result | ||
| } | ||
|
|
||
| // projectFunctionsPath is the path `cloud functions list` reads. | ||
| const projectFunctionsPath = "/projects/" + mainTestProjectAlpha + "/functions" | ||
|
|
||
| func TestGrantCallsProjectRoutesWithTheGrant(t *testing.T) { | ||
| run := runAsWorker(t, func(w http.ResponseWriter, r *http.Request) { | ||
| if r.URL.Path == projectFunctionsPath { | ||
| respondExitTestJSON(w, http.StatusOK, `{"data":[],"has_more":false,"page":1,"limit":100,"total":0}`) | ||
| return | ||
| } | ||
| respondExitTestJSON(w, http.StatusForbidden, `{"error":"delegated grants cannot be used on this endpoint","code":"grant_route_not_allowed"}`) | ||
| }, "cloud", "functions", "list") | ||
|
|
||
| assert.Equal(t, 0, run.code, run.stderr) | ||
| assert.Equal(t, []string{"GET " + projectFunctionsPath}, run.requests) | ||
| assert.Equal(t, []string{"Bearer " + testGrant}, run.auth) | ||
| assert.NotContains(t, run.stderr, "VOLCANO_PROJECT_ID is deprecated", "the link file chose the project") | ||
| } | ||
|
|
||
| // Hosting's refusals keep their exit codes and messages. Only a spent budget | ||
| // gets its own code, because a new grant is the fix. | ||
| func TestGrantRefusalsExitCodes(t *testing.T) { | ||
| for _, tc := range []struct { | ||
| name string | ||
| status int | ||
| body string | ||
| code int | ||
| want string | ||
| }{ | ||
| { | ||
| "route not allowed", http.StatusForbidden, | ||
| `{"error":"delegated grants cannot be used on this endpoint","code":"grant_route_not_allowed"}`, | ||
| exitPermissionDenied, "delegated grants cannot be used on this endpoint", | ||
| }, | ||
| { | ||
| "permission missing", http.StatusForbidden, | ||
| `{"error":"the delegated grant does not carry the permission this endpoint needs","code":"grant_permission_missing"}`, | ||
| exitPermissionDenied, "the delegated grant does not carry the permission this endpoint needs", | ||
| }, | ||
| { | ||
| "budget exhausted", http.StatusTooManyRequests, | ||
| `{"error":"the delegated grant has no mutations left; ask the app for a new grant","code":"grant_budget_exhausted"}`, | ||
| exitGrantBudgetExhausted, "the delegated grant has no mutations left; ask the app for a new grant", | ||
| }, | ||
| // Any other 429, such as the per-grant rate limit, is not the budget. | ||
| { | ||
| "rate limited", http.StatusTooManyRequests, | ||
| `{"error":"rate limit exceeded","code":"rate_limit_exceeded"}`, exitFailure, "rate limit exceeded", | ||
| }, | ||
| { | ||
| "expired or revoked", http.StatusUnauthorized, | ||
| `{"error":"invalid or expired token"}`, exitNotAuthenticated, "invalid or expired token", | ||
| }, | ||
| } { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| run := runAsWorker(t, func(w http.ResponseWriter, _ *http.Request) { | ||
| respondExitTestJSON(w, tc.status, tc.body) | ||
| }, "cloud", "functions", "list") | ||
|
|
||
| assert.Equal(t, tc.code, run.code, run.stderr) | ||
| assert.Contains(t, run.stderr, tc.want) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| // A grant was an unknown prefix, so an account token, before the CLI knew it. | ||
| // Account commands now refuse it before any request, as they refuse a pt-. | ||
| func TestGrantIsRefusedOnAccountCommandsBeforeAnyRequest(t *testing.T) { | ||
| for _, args := range [][]string{ | ||
| {"projects", "list"}, | ||
| {"cloud", "functions", "list", "--project", "some-project-name"}, | ||
| } { | ||
| t.Run(strings.Join(args, " "), func(t *testing.T) { | ||
| run := runAsWorker(t, func(w http.ResponseWriter, r *http.Request) { | ||
| t.Errorf("an account call reached the API: %s %s", r.Method, r.URL.Path) | ||
| respondExitTestJSON(w, http.StatusForbidden, `{"error":"forbidden"}`) | ||
| }, args...) | ||
|
|
||
| assert.Equal(t, exitPermissionDenied, run.code, run.stderr) | ||
| assert.Contains(t, run.stderr, "this command needs an account token") | ||
| assert.Contains(t, run.stderr, "the current credential is a delegated grant (pg-)") | ||
| assert.NotContains(t, run.stderr, "project access token") | ||
| assert.Empty(t, run.requests) | ||
| }) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,6 +37,9 @@ const ( | |
| exitNoProject = 3 | ||
| exitNotFound = 4 | ||
| exitPermissionDenied = 5 | ||
| // exitGrantBudgetExhausted is the API's 429 grant_budget_exhausted: a | ||
| // delegated grant used all of its mutations, and a new grant is the fix. | ||
| exitGrantBudgetExhausted = 6 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ⚪ P3: Link the required companion documentation PR AGENTS.md requires a cross-linked Kong/volcano-docs PR for user-facing changes. The supplied description and comments contain no companion link and still cite removed local docs, including docs/scripting.md. Open or link the companion PR updating content/cli for grant handling and exit code 6. Found by gpt-6.1-sol |
||
| ) | ||
|
|
||
| func main() { | ||
|
|
@@ -187,6 +190,8 @@ func printProjectEnvDeprecation(w io.Writer, used bool) { | |
| func exitCode(err error) int { | ||
| status := api.Status(err) | ||
| switch { | ||
| case api.GrantBudgetExhausted(err): | ||
| return exitGrantBudgetExhausted | ||
| case errors.Is(err, config.ErrNotAuthenticated), status == http.StatusUnauthorized: | ||
| return exitNotAuthenticated | ||
| case errors.Is(err, config.ErrNoProjectSelected), errors.Is(err, config.ErrNoProjectSelectedForProjectToken): | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| package api | ||
|
|
||
| import ( | ||
| "errors" | ||
| "net/http" | ||
| ) | ||
|
|
||
| // GrantBudgetExhaustedCode is the API's 429 code for a change made after a | ||
| // delegated grant used all of its mutations. A new grant gets a new budget. | ||
| const GrantBudgetExhaustedCode = "grant_budget_exhausted" | ||
|
|
||
| // GrantBudgetExhausted reports whether err is the API's 429 for a delegated | ||
| // grant with no mutations left. | ||
| func GrantBudgetExhausted(err error) bool { | ||
| apiErr, ok := errors.AsType[*Error](err) | ||
| return ok && apiErr.StatusCode == http.StatusTooManyRequests && apiErr.Code == GrantBudgetExhaustedCode | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
⚪ P3: Docs for exit code 6 and delegated grants were dropped in the merge, and no volcano-docs PR is linked
After the merge of main (#489 moved published docs to Kong/volcano-docs), this diff no longer touches
docs/scripting.md,docs/authentication.md,docs/feedback.md,docs/lavaflow.md,docs/durable-functions.md,docs/domains.mdordocs/git.md. The PR description still lists them under Summary, and its Tracking section links no Kong/volcano-docs PR. AGENTS.md says a change to user-facing behavior needs a companion PR in Kong/volcano-docs that updatescontent/cli, and that the two PRs must be linked. Without one, the published scripting page will not list exit code 6, which the Builder worker relies on. The authentication, feedback, LavaFlow and approvals pages also won't mention delegated grants. Fix: move those edits into a Kong/volcano-docs PR undercontent/cli, following itsspec/markdown-format.md, link it here, and update the description's docs bullet.Found by claude-opus-5-5