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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,8 +259,11 @@ preview, request/response logs, and response envelope headers.

Action-specific credential restrictions belong in the Go command. Use
`runtime.EnsureCredential()` to load or reuse credentials before checking them.
`apigateway apply_gateway_permission` rejects personal credentials and explicit
Bearer headers locally, including dry-run.
`apigateway apply_gateway_permission` requires `app_user` credentials with non-empty
`bk_app_code` and `bk_app_secret`. It rejects access-token/personal-token credentials
and explicit Bearer headers locally, including dry-run. An explicit
`X-Bkapi-Authorization` must also contain non-empty app code/secret and no
`access_token` or `personal_token` fields.

Exact header payload shapes, validation, and redaction behavior remain defined in `docs/design.md` and the shared request/runtime packages.

Expand Down
50 changes: 35 additions & 15 deletions cmd/system/apigateway/apply_gateway_permission.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"fmt"
"strings"

json "github.com/goccy/go-json"
"github.com/spf13/cobra"

"github.com/TencentBlueKing/bk-cli/internal/api"
Expand Down Expand Up @@ -53,11 +54,15 @@ Batch required resource_names into one request per gateway to avoid excessive
application records. Do not retry automatically: a retry can create another record.
Use --dry-run to preview without confirmation or submitting an application.

This API requires application identity and does not support personal_token.
Personal-token contexts and explicit Authorization: Bearer headers are rejected
locally with unsupported_credential_type, including during --dry-run. Select an
application credential context or log in with app credentials or a suitable
access_token before previewing or submitting.
Only bk_app_code + bk_app_secret authentication is supported by this action.
access_token and personal_token contexts are forbidden, including during --dry-run.
Authorization: Bearer and X-Bkapi-Authorization overrides containing access_token
or personal_token are rejected locally with unsupported_credential_type. An
X-Bkapi-Authorization override must contain non-empty bk_app_code and bk_app_secret.
Agents must stop if only tokens are available and ask the application developer
to handle the permission application. Do not bypass this restriction with raw API
calls, header overrides or automatic retries. Select an authorized app credential
context before previewing or submitting; user confirmation is still required.

The required --body accepts JSON, @file or - for stdin.
Before preview or submission, the shared request layer reads the input and checks
Expand Down Expand Up @@ -125,7 +130,7 @@ Do not invent approval links or approvers, or assume the CLI knows approval stat
if err := runtime.EnsureCredential(); err != nil {
return err
}
if err := validatePersonalTokenAuth(runtime.Credential, headerMap); err != nil {
if err := validatePermissionApplicationAuth(runtime.Credential, headerMap); err != nil {
return err
}
return systemcmd.ExecuteRequest(cmd, runtime, "apply_gateway_permission", syslib.RequestSpec{
Expand Down Expand Up @@ -175,21 +180,36 @@ Do not invent approval links or approvers, or assume the CLI knows approval stat
return cmd
}

func validatePersonalTokenAuth(cred *credential.Credential, headers map[string]string) error {
personalAuth := cred != nil && cred.Type == credential.TypePersonalToken
func validatePermissionApplicationAuth(cred *credential.Credential, headers map[string]string) error {
unsupported := cred == nil || cred.Type != credential.TypeAppUser ||
strings.TrimSpace(cred.BkAppCode) == "" || strings.TrimSpace(cred.BkAppSecret) == ""
for name, value := range headers {
if strings.EqualFold(name, "Authorization") {
switch {
case strings.EqualFold(name, "Authorization"):
parts := strings.Fields(value)
personalAuth = personalAuth || (len(parts) > 0 && strings.EqualFold(parts[0], "Bearer"))
unsupported = unsupported || (len(parts) > 0 && strings.EqualFold(parts[0], "Bearer"))
case strings.EqualFold(name, "X-Bkapi-Authorization"):
var auth map[string]string
if err := json.Unmarshal([]byte(value), &auth); err != nil {
unsupported = true
continue
}
_, accessToken := auth["access_token"]
_, personalToken := auth["personal_token"]
unsupported = unsupported || accessToken || personalToken ||
strings.TrimSpace(
auth["bk_app_code"],
) == "" || strings.TrimSpace(auth["bk_app_secret"]) == ""
}
}
if personalAuth {
if unsupported {
return output.UserError(
"unsupported_credential_type",
"personal_token authentication is not supported for this action; application identity is required",
"Select an application credential context using --context, or run bk-cli auth login with "+
"--bk_app_code/--bk_app_secret and --bk_token/--bk_ticket, or a suitable --access_token; "+
"remove any Authorization: Bearer override before previewing or submitting.",
"this action requires application identity via bk_app_code + bk_app_secret; "+
"access_token and personal_token authentication are forbidden",
"Agents must stop if only tokens are available and ask the application developer to apply. "+
"Select an authorized app credential context with --context; remove token header overrides. "+
"Do not bypass this restriction with raw API calls or retry automatically.",
)
}
return nil
Expand Down
149 changes: 119 additions & 30 deletions cmd/system/apigateway/apply_gateway_permission_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@ var _ = Describe("apigateway apply_gateway_permission", func() {
var status int
var server *httptest.Server

setupAppContext := func() {
Expect(systemtest.SetupTestContext(server.URL)).To(Succeed())
key, err := credential.DeriveKey()
Expect(err).NotTo(HaveOccurred())
Expect(credential.Save(config.CredentialsPath("default"), &credential.Credential{
Type: credential.TypeAppUser,
BkAppCode: "my-app",
BkAppSecret: "test-secret",
BkToken: "test-user-token",
}, key)).To(Succeed())
}

newCommand := func(dryRun bool) *cobra.Command {
parent := &cobra.Command{Use: "apigateway"}
Expect(NewSystemSpec().RegisterGoActions(parent, systemtest.BuildDeps(dryRun))).To(Succeed())
Expand Down Expand Up @@ -96,7 +108,7 @@ var _ = Describe("apigateway apply_gateway_permission", func() {

DescribeTable("refuses every submission without explicit user confirmation",
func(args []string) {
Expect(systemtest.SetupTestContext(server.URL)).To(Succeed())
setupAppContext()
cmd := newCommand(false)
args = append(args, "--gateway_name", "bk-iam", "--body", body)
Expect(cmd.ParseFlags(args)).To(Succeed())
Expand All @@ -114,6 +126,9 @@ var _ = Describe("apigateway apply_gateway_permission", func() {
cmd.SetOut(&help)
Expect(cmd.Help()).To(Succeed())
Expect(help.String()).To(ContainSubstring("must ask the user"))
Expect(help.String()).To(ContainSubstring("Only bk_app_code + bk_app_secret"))
Expect(help.String()).To(ContainSubstring("Agents must stop"))
Expect(help.String()).To(ContainSubstring("access_token and personal_token contexts are forbidden"))
Expect(help.String()).To(ContainSubstring("every submission"))
Expect(help.String()).To(ContainSubstring("cannot be skipped"))
Expect(help.String()).To(ContainSubstring("Batch"))
Expand All @@ -126,7 +141,7 @@ var _ = Describe("apigateway apply_gateway_permission", func() {
})

It("previews an unconfirmed batch without sending a request or claiming submission", func() {
Expect(systemtest.SetupTestContext(server.URL)).To(Succeed())
setupAppContext()
cmd := newCommand(true)
Expect(cmd.Flags().Set("gateway_name", "bk-iam")).To(Succeed())
Expect(cmd.Flags().Set("body", body)).To(Succeed())
Expand All @@ -148,15 +163,19 @@ var _ = Describe("apigateway apply_gateway_permission", func() {
Expect(requests.Load()).To(BeZero())
})

DescribeTable("rejects personal token authentication before preview or submission",
func(dryRun, personalContext bool, headers []string) {
Expect(systemtest.SetupTestContext(server.URL)).To(Succeed())
if personalContext {
DescribeTable("rejects token authentication before preview or submission",
func(dryRun bool, credType credential.CredentialType, headers []string) {
setupAppContext()
if credType != credential.TypeAppUser {
key, err := credential.DeriveKey()
Expect(err).NotTo(HaveOccurred())
Expect(credential.Save(config.CredentialsPath("default"), &credential.Credential{
Type: credential.TypePersonalToken, PersonalToken: "test-personal-token",
}, key)).To(Succeed())
cred := &credential.Credential{Type: credType}
if credType == credential.TypePersonalToken {
cred.PersonalToken = "test-personal-token"
} else {
cred.AccessToken = "test-access-token"
}
Expect(credential.Save(config.CredentialsPath("default"), cred, key)).To(Succeed())
}
cmd := newCommand(dryRun)
args := []string{"--gateway_name", "bk-iam", "--body", body}
Expand All @@ -173,47 +192,112 @@ var _ = Describe("apigateway apply_gateway_permission", func() {
Expect(err).To(MatchError(ContainSubstring("unsupported_credential_type")))
Expect(err.Error()).To(ContainSubstring("application identity"))
Expect(err.Error()).NotTo(ContainSubstring("test-personal-token"))
Expect(err.Error()).NotTo(ContainSubstring("test-access-token"))
Expect(stdout.String()).To(BeEmpty())
Expect(requests.Load()).To(BeZero())
},
Entry("personal context submission", false, true, nil),
Entry("personal context preview", true, true, nil),
Entry("personal context with app header override", false, true,
[]string{`x-bkapi-authorization:{"access_token":"app-token"}`}),
Entry("app context with Bearer header submission", false, false,
Entry("access token submission", false, credential.TypeAccessToken, nil),
Entry("access token preview", true, credential.TypeAccessToken, nil),
Entry("access token with app header override", false, credential.TypeAccessToken,
[]string{`X-Bkapi-Authorization:{"bk_app_code":"my-app","bk_app_secret":"test-secret"}`}),
Entry("app context with access token header", false, credential.TypeAppUser,
[]string{`x-bkapi-authorization:{"access_token":"test-access-token"}`}),
Entry(
"app context with mixed auth preview",
true,
credential.TypeAppUser,
[]string{
`X-Bkapi-Authorization:{"bk_app_code":"my-app","bk_app_secret":"test-secret","access_token":"test-access-token"}`,
},
),

Entry("malformed auth header", false, credential.TypeAppUser,
[]string{`X-Bkapi-Authorization:{test-access-token`}),
Entry("missing app secret override", true, credential.TypeAppUser,
[]string{`X-Bkapi-Authorization:{"bk_app_code":"my-app"}`}),
Entry("null auth override", false, credential.TypeAppUser,
[]string{`X-Bkapi-Authorization:null`}),
Entry("non-string app secret", false, credential.TypeAppUser,
[]string{`X-Bkapi-Authorization:{"bk_app_code":"my-app","bk_app_secret":123}`}),
Entry(
"personal token in app override",
false,
credential.TypeAppUser,
[]string{
`X-Bkapi-Authorization:{"bk_app_code":"my-app","bk_app_secret":"test-secret","personal_token":"test-personal-token"}`,
},
),
Entry(
"empty access token in app override",
true,
credential.TypeAppUser,
[]string{
`X-Bkapi-Authorization:{"bk_app_code":"my-app","bk_app_secret":"test-secret","access_token":""}`,
},
),
Entry("last case-insensitive override contains token", false, credential.TypeAppUser,
[]string{
`X-Bkapi-Authorization:{"bk_app_code":"my-app","bk_app_secret":"test-secret"}`,
`x-bkapi-authorization:{"access_token":"test-access-token"}`,
}),

Entry("personal context submission", false, credential.TypePersonalToken, nil),
Entry("personal context preview", true, credential.TypePersonalToken, nil),
Entry("personal context with app header override", false, credential.TypePersonalToken,
[]string{`x-bkapi-authorization:{"bk_app_code":"my-app","bk_app_secret":"test-secret"}`}),
Entry("app context with Bearer header submission", false, credential.TypeAppUser,
[]string{"authorization:bEaReR test-personal-token"}),
Entry("app context with Bearer header preview", true, false,
Entry("app context with Bearer header preview", true, credential.TypeAppUser,
[]string{"Authorization:Bearer test-personal-token"}),
)

DescribeTable("checks command-local personal token restrictions for cached credentials and header overrides",
DescribeTable(
"checks command-local application credential restrictions for cached credentials and header overrides",
func(credType credential.CredentialType, headers map[string]string, rejected bool) {
var cred *credential.Credential
if credType != "" {
cred = &credential.Credential{Type: credType}
cred = &credential.Credential{
Type: credType,
BkAppCode: "my-app",
BkAppSecret: "test-secret",
}
}
err := validatePersonalTokenAuth(cred, headers)
err := validatePermissionApplicationAuth(cred, headers)
if rejected {
Expect(err).To(MatchError(ContainSubstring("unsupported_credential_type")))
} else {
Expect(err).NotTo(HaveOccurred())
}
},
Entry("anonymous without headers", credential.CredentialType(""), nil, false),
Entry("anonymous without headers", credential.CredentialType(""), nil, true),
Entry("application credentials", credential.TypeAppUser, nil, false),
Entry("access token credentials", credential.TypeAccessToken, nil, false),
Entry("access token credentials", credential.TypeAccessToken, nil, true),
Entry("cached personal credentials", credential.TypePersonalToken, nil, true),
Entry("explicit Bearer header without stored credentials", credential.CredentialType(""),
map[string]string{"aUtHoRiZaTiOn": " bEaReR personal"}, true),
Entry("non-Bearer header", credential.TypeAccessToken,
Entry("non-Bearer header", credential.TypeAppUser,
map[string]string{"Authorization": "Basic example"}, false),
Entry("empty header", credential.TypeAccessToken,
Entry("empty header", credential.TypeAppUser,
map[string]string{"Authorization": ""}, false),
)

It("allows an app credential header override and preserves header precedence", func() {
setupAppContext()
cmd := newCommand(false)
Expect(cmd.ParseFlags([]string{
"--confirmed", "--gateway_name", "bk-iam", "--body", body,
"--header", `X-Bkapi-Authorization:{"access_token":"discarded-token"}`,
"--header", `x-bkapi-authorization:{"bk_app_code":"my-app","bk_app_secret":"override-secret"}`,
})).To(Succeed())
Expect(cmd.RunE(cmd, nil)).To(Succeed())
Expect(requests.Load()).To(Equal(int32(1)))
Expect(capturedHeader.Get("X-Bkapi-Authorization")).To(MatchJSON(
`{"bk_app_code":"my-app","bk_app_secret":"override-secret"}`))
})

DescribeTable("submits exactly one batch and preserves all returned approval details",
func(input string) {
Expect(systemtest.SetupTestContext(server.URL)).To(Succeed())
setupAppContext()
bodyInput := body
if input == "file" {
file := filepath.Join(GinkgoT().TempDir(), "body.json")
Expand Down Expand Up @@ -249,7 +333,12 @@ var _ = Describe("apigateway apply_gateway_permission", func() {
)
Expect(capturedBody).To(MatchJSON(body))
Expect(capturedHeader.Get("X-Custom")).To(Equal("batch"))
Expect(capturedHeader.Get("X-Bkapi-Authorization")).NotTo(BeEmpty())
Expect(
capturedHeader.Get("X-Bkapi-Authorization"),
).To(
MatchJSON(`{"bk_app_code":"my-app","bk_app_secret":"test-secret"}`),
)
Expect(capturedHeader.Get("Authorization")).To(BeEmpty())
var env output.Envelope
Expect(json.Unmarshal(stdout.Bytes(), &env)).To(Succeed())
Expect(env.OK).To(BeTrue())
Expand All @@ -272,7 +361,7 @@ var _ = Describe("apigateway apply_gateway_permission", func() {

DescribeTable("rejects invalid local inputs before contacting the gateway",
func(gateway, input, errorCode string) {
Expect(systemtest.SetupTestContext(server.URL)).To(Succeed())
setupAppContext()
cmd := newCommand(false)
Expect(
cmd.ParseFlags([]string{"--confirmed", "--gateway_name", gateway, "--body", input}),
Expand All @@ -292,7 +381,7 @@ var _ = Describe("apigateway apply_gateway_permission", func() {
DescribeTable(
"delegates permission fields and body structure to the server",
func(input string) {
Expect(systemtest.SetupTestContext(server.URL)).To(Succeed())
setupAppContext()
status = http.StatusBadRequest
response = `{"code":1,"message":"Server rejected application fields"}`
cmd := newCommand(false)
Expand Down Expand Up @@ -377,7 +466,7 @@ var _ = Describe("apigateway apply_gateway_permission", func() {

DescribeTable("rejects invalid JSON from inline, file and stdin during preview and submission",
func(source string, dryRun bool) {
Expect(systemtest.SetupTestContext(server.URL)).To(Succeed())
setupAppContext()
input := `{`
bodyInput := input
if source == "file" {
Expand Down Expand Up @@ -417,7 +506,7 @@ var _ = Describe("apigateway apply_gateway_permission", func() {
DescribeTable(
"keeps application bodies unchanged as the server contract evolves",
func(input string) {
Expect(systemtest.SetupTestContext(server.URL)).To(Succeed())
setupAppContext()
cmd := newCommand(false)
Expect(
cmd.ParseFlags([]string{"--confirmed", "--gateway_name", "bk-iam", "--body", input}),
Expand Down Expand Up @@ -453,7 +542,7 @@ var _ = Describe("apigateway apply_gateway_permission", func() {
DescribeTable(
"only reports submission when the upstream returns an application record",
func(upstream, recordID string) {
Expect(systemtest.SetupTestContext(server.URL)).To(Succeed())
setupAppContext()
response = upstream
cmd := newCommand(false)
Expect(
Expand Down Expand Up @@ -512,7 +601,7 @@ var _ = Describe("apigateway apply_gateway_permission", func() {
)

It("preserves upstream errors without a successful application message", func() {
Expect(systemtest.SetupTestContext(server.URL)).To(Succeed())
setupAppContext()
status = http.StatusForbidden
response = `{"code":1640301,"message":"Permission denied"}`
cmd := newCommand(false)
Expand Down
Loading
Loading