From a4cd4ac96b57613233eb27d94054a91e6520f662 Mon Sep 17 00:00:00 2001 From: robot-wklken Date: Mon, 26 Oct 2026 20:23:09 +0800 Subject: [PATCH] feat(apigateway): list current app permissions --- cmd/system/apigateway/actions.yaml | 52 ++++ cmd/system/apigateway_permissions_test.go | 227 ++++++++++++++++++ skills/bk-cli-apigateway/SKILL.md | 50 +++- ...igateway-list-current-app-permissions.yaml | 88 +++++++ 4 files changed, 412 insertions(+), 5 deletions(-) create mode 100644 cmd/system/apigateway_permissions_test.go create mode 100644 tests/integration/cases/system/apigateway/APIGW-010-apigateway-list-current-app-permissions.yaml diff --git a/cmd/system/apigateway/actions.yaml b/cmd/system/apigateway/actions.yaml index ed66666..ace86c1 100644 --- a/cmd/system/apigateway/actions.yaml +++ b/cmd/system/apigateway/actions.yaml @@ -86,6 +86,58 @@ actions: - "bk-cli apigateway retrieve_gateway_api_details --gateway_name bk-iam --api_name create_system" timeout: 30s + - name: list_current_app_permissions + description: | + List gateway or resource permissions for the current authenticated app. + The server identifies the app from request credentials; select them with --context. + Returns a single page: limit defaults to 10 and offset to 0. Compare data.data.count + with data.data.results and increase offset to read subsequent pages. + Results may include expired records: expires null means permanent; otherwise check + the expiry time before treating a record as a valid permission. + Query both dimensions when checking an API: valid gateway grants also authorize + its resources, and resource_name is empty in the gateway dimension. + method: GET + path: "/api/v2/open/app/permissions/" + authConfig: + appVerifiedRequired: true + userVerifiedRequired: false + resourcePermissionRequired: false + params: + - name: grant_dimension + in: query + type: string + description: "[Required] Permission dimension: gateway or resource" + required: true + - name: gateway_name + in: query + type: string + description: "Gateway name filter (exact match)" + required: false + - name: resource_name + in: query + type: string + description: "Resource name filter (exact match; only effective in resource dimension)" + required: false + - name: limit + in: query + type: int + description: "Page size (upstream default: 10; returns a single page)" + required: false + default: "10" + - name: offset + in: query + type: int + description: "Offset into the full result list (upstream default: 0)" + required: false + default: "0" + examples: + - "bk-cli apigateway list_current_app_permissions --grant_dimension gateway" + - "bk-cli apigateway list_current_app_permissions --grant_dimension resource" + - "bk-cli apigateway list_current_app_permissions --grant_dimension resource --gateway_name bk-demo --resource_name user_query" + - "bk-cli apigateway list_current_app_permissions --grant_dimension resource --limit 10 --offset 10" + - "bk-cli apigateway list_current_app_permissions --context my-app --grant_dimension gateway --jq '.data.data'" + timeout: 30s + - name: query_log_by_request_id description: "Query API gateway log by request ID" method: GET diff --git a/cmd/system/apigateway_permissions_test.go b/cmd/system/apigateway_permissions_test.go new file mode 100644 index 0000000..1e618fa --- /dev/null +++ b/cmd/system/apigateway_permissions_test.go @@ -0,0 +1,227 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions and + * limitations under the License. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package system + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + + json "github.com/goccy/go-json" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/cobra" + + systemtest "github.com/TencentBlueKing/bk-cli/cmd/system/testutil" + "github.com/TencentBlueKing/bk-cli/internal/config" + "github.com/TencentBlueKing/bk-cli/internal/credential" + "github.com/TencentBlueKing/bk-cli/internal/output" + systemcmd "github.com/TencentBlueKing/bk-cli/internal/systemcmd" +) + +var _ = Describe("apigateway list_current_app_permissions", func() { + const response = `{"data":{"count":30,"results":[ + {"id":9007199254740993,"bk_app_code":"my-app","gateway_name":"bk-demo", + "resource_name":"","expires":null,"grant_type":"initialize"}, + {"id":2,"bk_app_code":"my-app","gateway_name":"bk-demo", + "resource_name":"user_query","expires":"2020-01-01 00:00:00 +0800","grant_type":"apply"} + ]}}` + + buildCommand := func(dryRun bool) *cobra.Command { + root := &cobra.Command{Use: "bk-cli"} + Expect(registerSystemSpecs(root, []systemcmd.SystemSpec{newApigatewaySystemSpec()}, + systemtest.BuildDeps(dryRun), actionsFS)).To(Succeed()) + cmd, _, err := root.Find([]string{"apigateway", "list_current_app_permissions"}) + Expect(err).NotTo(HaveOccurred()) + Expect(cmd.Name()).To(Equal("list_current_app_permissions")) + return cmd + } + + BeforeEach(func() { + GinkgoT().Setenv("BK_CLI_CONFIG_DIR", GinkgoT().TempDir()) + }) + + DescribeTable("sends one current-app query and preserves the complete upstream page", + func(flags map[string]string, query url.Values) { + var captured *http.Request + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = r.Clone(r.Context()) + calls++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(response)) + })) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + cmd := buildCommand(false) + for name, value := range flags { + Expect(cmd.Flags().Set(name, value)).To(Succeed()) + } + Expect(cmd.Flags().Set("stage", "testing")).To(Succeed()) + Expect(cmd.Flags().Set("header", "X-Request-Id:permissions-test")).To(Succeed()) + stdout, err := systemtest.CaptureCommandStdout(func() error { return cmd.RunE(cmd, nil) }) + Expect(err).NotTo(HaveOccurred()) + Expect(calls).To(Equal(1)) + Expect(captured.Method).To(Equal(http.MethodGet)) + Expect(captured.URL.Path).To(Equal("/bk-apigateway/testing/api/v2/open/app/permissions/")) + Expect(captured.URL.Query()).To(Equal(query)) + Expect(captured.Header.Get("X-Request-Id")).To(Equal("permissions-test")) + Expect( + captured.Header.Get("X-Bkapi-Authorization"), + ).To( + MatchJSON(`{"access_token":"token-123"}`), + ) + var envelope output.Envelope + Expect(json.Unmarshal([]byte(stdout), &envelope)).To(Succeed()) + Expect(envelope.OK).To(BeTrue()) + Expect(envelope.Status).To(Equal(200)) + Expect(stdout).To(ContainSubstring("9007199254740993")) + // A page must retain expired and permanent records and its total count. + var upstream any + Expect(json.Unmarshal([]byte(response), &upstream)).To(Succeed()) + Expect(envelope.Data).To(Equal(upstream)) + }, + Entry("gateway dimension with server pagination defaults", + map[string]string{"grant_dimension": "gateway"}, + url.Values{"grant_dimension": {"gateway"}}), + Entry("resource dimension with exact filters and explicit pagination", + map[string]string{ + "grant_dimension": "resource", "gateway_name": "bk-demo", + "resource_name": "user query/+", "limit": "10", "offset": "10", + }, + url.Values{ + "grant_dimension": {"resource"}, "gateway_name": {"bk-demo"}, + "resource_name": {"user query/+"}, "limit": {"10"}, "offset": {"10"}, + }), + Entry("explicit zero pagination values are passed to the upstream", + map[string]string{"grant_dimension": "resource", "limit": "0", "offset": "0"}, + url.Values{"grant_dimension": {"resource"}, "limit": {"0"}, "offset": {"0"}}), + ) + + DescribeTable("uses shared authentication without permission-application restrictions", + func(cred *credential.Credential, expectedHeader, expectedValue string) { + var headers http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + headers = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(response)) + })) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + key, err := credential.DeriveKey() + Expect(err).NotTo(HaveOccurred()) + Expect(credential.Save(config.CredentialsPath("default"), cred, key)).To(Succeed()) + cmd := buildCommand(false) + Expect(cmd.Flags().Set("grant_dimension", "gateway")).To(Succeed()) + _, err = systemtest.CaptureCommandStdout(func() error { return cmd.RunE(cmd, nil) }) + Expect(err).NotTo(HaveOccurred()) + Expect(headers.Get(expectedHeader)).To(Equal(expectedValue)) + }, + Entry( + "app identity without sending user credentials", + &credential.Credential{ + Type: credential.TypeAppUser, + BkAppCode: "my-app", + BkAppSecret: "test-secret", + BkToken: "test-user", + }, + "X-Bkapi-Authorization", + `{"bk_app_code":"my-app","bk_app_secret":"test-secret"}`, + ), + Entry("personal identity resolved by the server", + &credential.Credential{Type: credential.TypePersonalToken, PersonalToken: "test-personal"}, + "Authorization", "Bearer test-personal"), + ) + + It("rejects a missing dimension before sending a request", func() { + Expect(systemtest.SetupTestContext("https://bkapi.example.com")).To(Succeed()) + cmd := buildCommand(false) + err := cmd.RunE(cmd, nil) + var cliErr *output.CLIError + Expect(err).To(BeAssignableToTypeOf(cliErr)) + Expect(err.(*output.CLIError).Code).To(Equal("missing_param")) + Expect(err.Error()).To(ContainSubstring("--grant_dimension")) + }) + + It("previews a redacted request and supports jq without contacting the upstream", func() { + Expect(systemtest.SetupTestContext("https://bkapi.example.com")).To(Succeed()) + cmd := buildCommand(true) + Expect(cmd.Flags().Set("grant_dimension", "resource")).To(Succeed()) + Expect( + cmd.Flags().Set( + "header", + `X-Bkapi-Authorization:{"bk_app_code":"my-app","bk_app_secret":"private-secret"}`, + ), + ).To( + Succeed(), + ) + Expect( + cmd.Flags().Set( + "jq", + `{dry_run,method:.request.method,params:.request.params,auth:.request.headers["X-Bkapi-Authorization"]}`, + ), + ).To( + Succeed(), + ) + stdout, err := systemtest.CaptureCommandStdout(func() error { return cmd.RunE(cmd, nil) }) + Expect(err).NotTo(HaveOccurred()) + Expect(stdout).NotTo(ContainSubstring("private-secret")) + Expect(stdout).NotTo(ContainSubstring("token-123")) + Expect(stdout).To(MatchJSON(`{"dry_run":true,"method":"GET","params":{"grant_dimension":"resource"}, + "auth":"{...redacted...}"}`)) + }) + + It("preserves failed upstream envelopes when jq is supplied", func() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"permission denied"}`)) + })) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + cmd := buildCommand(false) + Expect(cmd.Flags().Set("grant_dimension", "resource")).To(Succeed()) + Expect(cmd.Flags().Set("jq", ".data.data.results")).To(Succeed()) + stdout, err := systemtest.CaptureCommandStdout(func() error { return cmd.RunE(cmd, nil) }) + Expect(err).NotTo(HaveOccurred()) + var envelope output.Envelope + Expect(json.Unmarshal([]byte(stdout), &envelope)).To(Succeed()) + Expect(envelope.OK).To(BeFalse()) + Expect(envelope.Status).To(Equal(403)) + Expect(stdout).To(ContainSubstring("permission denied")) + }) + + It("documents pagination and permission interpretation in help without requiring a context", func() { + cmd := buildCommand(false) + Expect(cmd.Flag("target_app_code")).To(BeNil()) + Expect(cmd.Flag("limit").DefValue).To(Equal("10")) + Expect(cmd.Flag("offset").DefValue).To(Equal("0")) + var help bytes.Buffer + cmd.SetOut(&help) + Expect(cmd.Help()).To(Succeed()) + for _, text := range []string{ + "current authenticated app", "single page", "count", "offset", "expires", + "null", "expired", "gateway grants", "resource_name is empty", "--grant_dimension gateway", + "--grant_dimension resource", "--gateway_name bk-demo", "--resource_name user_query", "--jq", + } { + Expect(help.String()).To(ContainSubstring(text)) + } + }) +}) diff --git a/skills/bk-cli-apigateway/SKILL.md b/skills/bk-cli-apigateway/SKILL.md index eb45b31..88ccead 100644 --- a/skills/bk-cli-apigateway/SKILL.md +++ b/skills/bk-cli-apigateway/SKILL.md @@ -1,11 +1,11 @@ --- name: bk-cli-apigateway -description: 当需要通过 `bk-cli apigateway` 发现 BlueKing API Gateway 中所有公开网关、浏览某个网关公开的 API/资源列表、获取 OpenAPI/Schema、判断某个接口该怎么调用、申请网关或批量 API 权限,或在 `apigateway` 子命令与 `bk-cli api` 之间做选择时使用。尤其是当用户还不知道或不确定该调用哪个网关、哪个公开接口、接口名可能是什么、只知道业务目标/关键词/资源名片段,或遇到网关资源权限不足时,也应优先使用这个 skill,先搜索再确认,而不是直接猜测接口。 +description: 当需要通过 `bk-cli apigateway` 发现 BlueKing API Gateway 中所有公开网关、浏览某个网关公开的 API/资源列表、获取 OpenAPI/Schema、判断某个接口该怎么调用、查询当前应用已有网关或资源权限、申请网关或批量 API 权限,或在 `apigateway` 子命令与 `bk-cli api` 之间做选择时使用。尤其是当用户还不知道或不确定该调用哪个网关、哪个公开接口、接口名可能是什么、只知道业务目标/关键词/资源名片段,或遇到网关资源权限不足时,也应优先使用这个 skill,先搜索再确认,而不是直接猜测接口。 --- # bk-cli apigateway — API Gateway 管理 -用于发现网关、浏览网关 API、获取 OpenAPI Schema,以及在用户明确确认后申请网关或批量 API 权限。 +用于发现网关、浏览网关 API、获取 OpenAPI Schema、查询当前应用已有权限,以及在用户明确确认后申请网关或批量 API 权限。 CRITICAL — 开始前 MUST 先用 Read 工具读取 `../bk-cli-shared/SKILL.md`。共享 skill 负责认证、context、tenant、stage、header/body、dry-run、verbose 等通用使用规则;本 skill 只补充 `apigateway` 命令自己的探索、调用与权限申请路径。 @@ -24,7 +24,7 @@ CRITICAL — 开始前 MUST 先用 Read 工具读取 `../bk-cli-shared/SKILL.md` 2. 再用 `list_gateway_apis` 浏览该网关公开资源,缩小到候选 API。 3. 如果结果不唯一,向用户展示候选网关/API,并明确说明你为什么认为它们相关,请用户确认最终目标。 4. 用 `retrieve_gateway_api_details` 查看完整 Schema、认证要求、方法、路径、请求/响应结构。 -5. 如果目标已经有合适的 `apigateway` 子命令,就继续使用该命令;权限申请按下文 `apply_gateway_permission` 的确认流程执行。 +5. 如果目标已经有合适的 `apigateway` 子命令,就继续使用该命令;遇到权限不足时先用 `list_current_app_permissions` 查询网关和资源两个维度,确认缺少有效授权后再按下文 `apply_gateway_permission` 的确认流程申请。 6. 如果需要真正调用某个公开 API,或目标接口尚未映射成 `apigateway` 子命令,就根据详情里的协议信息切换到 `bk-cli api` 做原始调用。 ## Agent 工作方式 @@ -47,7 +47,7 @@ CRITICAL — 开始前 MUST 先用 Read 工具读取 `../bk-cli-shared/SKILL.md` ### 从发现切换到调用 -- `bk-cli apigateway` 负责“找网关、找接口、看详情、看 Schema”,也提供需用户明确确认的 `apply_gateway_permission` 权限申请命令。 +- `bk-cli apigateway` 负责“找网关、找接口、看详情、看 Schema、查当前应用权限”,也提供需用户明确确认的 `apply_gateway_permission` 权限申请命令。 - `bk-cli api` 负责“按真实 method/path/query/body/header 发请求”。 - 只有在看过 `retrieve_gateway_api_details` 的结果之后,才去组装 `bk-cli api` 调用更稳妥。 - 首次调用、排障、或用户自己也不确定参数时,优先先生成 `bk-cli api ... --dry-run`,确认 URL、headers、body 后再真正发请求。 @@ -215,6 +215,46 @@ bk-cli apigateway retrieve_gateway_api_details --gateway_name --api_name bk-cli apigateway retrieve_gateway_api_details --gateway_name bk-iam --api_name create_system ``` +### list_current_app_permissions + +查询服务端识别的当前认证应用的网关或资源授权情况,属于只读操作,无需用户确认。通过 `--context` 选择对应应用凭据,无需且不支持 `target_app_code`。认证遵循共享凭据规则:可使用应用凭据、满足应用认证要求的 `access_token`,或部署支持的个人 token;查询对象由服务端确定。下文权限申请命令的专属凭据限制不适用于查询。 + +```bash +bk-cli apigateway list_current_app_permissions --grant_dimension \ + [--gateway_name ] [--resource_name ] [--limit ] [--offset ] +``` + +| Flag | 类型 | 必填 | 说明 | +|------|------|------|------| +| `--grant_dimension` | string | **是** | `gateway` 按网关查询;`resource` 按资源查询 | +| `--gateway_name` | string | 否 | 网关名称,精确匹配 | +| `--resource_name` | string | 否 | 资源名称,精确匹配,仅在 `resource` 维度生效 | +| `--limit` | int | 否 | 单页大小,接口默认 `10` | +| `--offset` | int | 否 | 相对于完整列表的起始偏移,接口默认 `0` | + +```bash +# 分别查询网关和资源授权 +bk-cli apigateway list_current_app_permissions --grant_dimension gateway +bk-cli apigateway list_current_app_permissions --grant_dimension resource + +# 查询指定网关、指定资源 +bk-cli apigateway list_current_app_permissions --grant_dimension resource \ + --gateway_name bk-demo --resource_name user_query + +# 查询下一页 +bk-cli apigateway list_current_app_permissions --grant_dimension resource --limit 10 --offset 10 + +# 选择应用 context 并提取单页结果 +bk-cli apigateway list_current_app_permissions --context my-app \ + --grant_dimension gateway --jq '.data.data' +``` + +响应保持完整 JSON envelope:`data.data.count` 是筛选后的总数,`data.data.results` 是当前页记录,包含 `bk_app_code`、`gateway_name`、`resource_name`、`expires`、`grant_type` 等字段。**命令只返回一页,不自动遍历分页;不能把单页结果当作完整列表。** 逐页增加 `offset` 并结合总数读取后续记录。 + +**查询结果可能包含已过期授权。** `expires` 为 `null` 表示永久有效;其他值需要结合其中的时区和当前时间判断是否过期,不能仅因记录存在就认定仍有权限。网关和资源维度分别查询,网关维度的 `resource_name` 为空;有效的网关授权也可覆盖该网关的资源,不能因资源维度未查到记录就重复申请。 + +Agent 应先在两个维度查询并完成分页、有效期检查,再判断是否需要申请。若缺少有效授权,汇总同一网关需要的资源,向用户说明应用、网关、资源范围、有效期和原因,取得本次明确确认后使用 `apply_gateway_permission` 批量申请。提交成功不代表权限已获批,后续仍需关注审批并重新查询。 + ### apply_gateway_permission 调用 `v2_open_apply_gateway_permission` 申请网关或批量 API 权限。需要应用身份;`target_app_code` 必须与请求使用的应用一致。 @@ -305,7 +345,7 @@ bk-cli apigateway list_gateway_apis --gateway_name bk-iam \ ``` 说明: -- `--gateway_name` 会在本地校验 `^[a-z][a-z0-9-]{2,29}$`,不合法时直接返回 `invalid_gateway_name`。 +- `list_gateway_apis`、`retrieve_gateway_api_details` 和 `apply_gateway_permission` 的 `--gateway_name` 会在本地校验 `^[a-z][a-z0-9-]{2,29}$`,不合法时直接返回 `invalid_gateway_name`;`list_current_app_permissions` 的同名参数是精确匹配的查询过滤条件。 - 其他通用请求输入与排障顺序以 `../bk-cli-shared/SKILL.md` 为准。 ## 原始 API 调用 diff --git a/tests/integration/cases/system/apigateway/APIGW-010-apigateway-list-current-app-permissions.yaml b/tests/integration/cases/system/apigateway/APIGW-010-apigateway-list-current-app-permissions.yaml new file mode 100644 index 0000000..cb02f61 --- /dev/null +++ b/tests/integration/cases/system/apigateway/APIGW-010-apigateway-list-current-app-permissions.yaml @@ -0,0 +1,88 @@ +id: APIGW-010 +name: current app permission query parameters and shared request behavior +tags: + - coverage-system_yaml + - suite-integration +steps: + - name: init mock context + command: [context, init, --bk_api_url_tmpl, "${MOCK_API_BASE_URL}/{gateway_name}"] + expect: + exit_code: 0 + - name: login + command: [auth, login, --access_token, integration-token] + expect: + exit_code: 0 + - name: query gateway grants with upstream pagination defaults + command: [apigateway, list_current_app_permissions, --grant_dimension, gateway] + expect: + exit_code: 0 + checks: + - path: ok + equals: true + - path: status + equals: 200 + - path: data.method + equals: GET + - path: data.path + equals: /bk-apigateway/prod/api/v2/open/app/permissions/ + - path: data.query + equals: {grant_dimension: gateway} + - path: data.body + equals: null + - name: filter a resource page with context stage headers and jq + command: + - apigateway + - list_current_app_permissions + - --context + - default + - --grant_dimension + - resource + - --gateway_name + - bk-demo + - --resource_name + - user_query + - --limit + - "10" + - --offset + - "10" + - --stage + - testing + - --header + - X-Mock-Scenario:APIGW-010 + - --jq + - .data + expect: + exit_code: 0 + checks: + - path: gateway + equals: bk-apigateway + - path: path + equals: /bk-apigateway/testing/api/v2/open/app/permissions/ + - path: scenario + equals: APIGW-010 + - path: query + equals: {grant_dimension: resource, gateway_name: bk-demo, resource_name: user_query, limit: "10", offset: "10"} + - name: preview without sending a request + command: [apigateway, list_current_app_permissions, --grant_dimension, resource, --dry-run, --insecure, --verbose] + expect: + exit_code: 0 + checks: + - path: dry_run + equals: true + - path: request.method + equals: GET + - path: request.params + equals: {grant_dimension: resource} + - path: request.headers.X-Bkapi-Authorization + equals: "{...redacted...}" + - name: require a grant dimension + command: [apigateway, list_current_app_permissions] + expect: + exit_code: 1 + checks: + - path: error.code + equals: command_error + - name: current app queries do not accept a target app + command: [apigateway, list_current_app_permissions, --grant_dimension, gateway, --target_app_code, other-app] + expect: + exit_code: 1