API Console
test
Sign in with BES

接入新产品

做完后,新产品(product)的用户通过登录客户端(client)用 BES 账号登录;产品带服务账号(service account)的证书,请 Control 按功能(feature)和角色(role)检查每个请求。

环境登录地址带证书调用的接口地址
localhttps://id.local.globalbes.sghttps://id.local.globalbes.sg:19443
testhttps://id.test.globalbes.sghttps://id.test.globalbes.sg:9443
staging待定待定
prod待定待定

Sign in to send requests to see which of these steps are done. Sign in

1. 登记产品,拿到凭据
1.1

创建产品

在哪里做:测试台

创建产品,得到 product_id。后面登记客户端、服务账号、功能和角色都要填它。

POSTProduct Create

在测试台点本步的 Product Create 标签,按下面的值填表(id 用前面步骤返回的),点 Send request。

填表的值:

{
  "request_ref": "create-bim",
  "code": "bim",
  "scope_level": "project",
  "display_name": "BIM"
}

返回:

{
  "product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
  "code": "bim",
  "scope_level": "project",
  "sellable": true,
  "display_name": "BIM",
  "status": "active",
  "version": 0
}
  • code:小写字母开头,只用小写字母、数字和 _.-,登记后不再改。
  • scope_level:按租户(tenant)卖写 tenant,按项目(project)卖写 project;project 的产品只能在项目里进入,角色和开通记录(entitlement)也按项目算。
  • status 不写时是 active;写成 inactive 的产品要先用 product.set-status 改成 active 才能登录。

错误:

HTTP 错误码 处理
409 PRODUCT_CODE_CONFLICT 这个 code 已被占用,换一个。
1.2

登记登录客户端

在哪里做:测试台

登记网页登录客户端,得到 client_secret。程序用 code 换令牌时带 client_id 和 client_secret。

POSTClient Register

在测试台点本步的 Client Register 标签,按下面的值填表(id 用前面步骤返回的),点 Send request。

填表的值:

{
  "request_ref": "register-bim-web",
  "client_id": "bim-web",
  "product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
  "deployment_version": "v1",
  "display_name": "BIM",
  "kind": "confidential_web",
  "auth_method": "client_secret_basic",
  "redirect_uris": [
    "https://bim.example.com/login/oauth2/code/bes",
    "https://bim.example.com/tenant/chosen"
  ],
  "post_logout_redirect_uris": [
    "https://bim.example.com/"
  ],
  "scopes": [
    "openid",
    "profile",
    "email"
  ],
  "token_profile": "web",
  "dpop_required": false
}

返回:

{
  "client_id": "bim-web",
  "display_name": "BIM",
  "kind": "confidential_web",
  "auth_method": "client_secret_basic",
  "redirect_uris": [
    "https://bim.example.com/login/oauth2/code/bes",
    "https://bim.example.com/tenant/chosen"
  ],
  "post_logout_redirect_uris": [
    "https://bim.example.com/"
  ],
  "backchannel_logout_uri": null,
  "scopes": [
    "openid",
    "profile",
    "email"
  ],
  "dpop_required": false,
  "token_profile": "web",
  "service_account_id": null,
  "status": "pending",
  "version": 0,
  "product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
  "public_key_jwk": null,
  "deployment_version": "v1",
  "client_secret": "<client_secret>"
}

登录客户端的取值:

  • redirect_uris、post_logout_redirect_uris:程序实际用的 HTTPS 地址,逐字符一致,包括路径和结尾的斜杠。
  • scopes:从 openid、profile、email 里选,必须有 openid。
  • client_secret 只在这次返回里出现,放进部署的密钥存储。
  • 返回的 status 是 pending,下一步启用。

错误:

HTTP 错误码 处理
409 CLIENT_ID_CONFLICT 这个 client_id 已被占用,换一个。
400 CLIENT_CONFIGURATION_INVALID 按上面的取值规则改参数后重发。
1.3

启用登录客户端

在哪里做:测试台

把登录客户端改成 active,得到可用的登录客户端。用户从此能通过它登录。

POSTClient Set Status

在测试台点本步的 Client Set Status 标签,按下面的值填表(id 用前面步骤返回的),点 Send request。

填表的值:

{
  "client_id": "bim-web",
  "request_ref": "activate-bim-web",
  "product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
  "status": "active"
}

返回:

{
  "client_id": "bim-web",
  "display_name": "BIM",
  "kind": "confidential_web",
  "auth_method": "client_secret_basic",
  "redirect_uris": [
    "https://bim.example.com/login/oauth2/code/bes",
    "https://bim.example.com/tenant/chosen"
  ],
  "post_logout_redirect_uris": [
    "https://bim.example.com/"
  ],
  "backchannel_logout_uri": null,
  "scopes": [
    "openid",
    "profile",
    "email"
  ],
  "dpop_required": false,
  "token_profile": "web",
  "service_account_id": null,
  "status": "active",
  "version": 1,
  "product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
  "public_key_jwk": null,
  "deployment_version": "v1"
}
1.4

创建服务账号

在哪里做:测试台

创建服务账号,得到 service_account_id。生成证书请求时写进证书。

POSTService Account Create

在测试台点本步的 Service Account Create 标签,按下面的值填表(id 用前面步骤返回的),点 Send request。

填表的值:

{
  "request_ref": "create-bim-server-account",
  "product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
  "display_name": "BIM server"
}

返回:

{
  "service_account_id": "9d2c4b7e-1f35-4a68-b0c9-6e8a3f5d2c17",
  "registered_client_id": "svc-9d2c4b7e-1f35-4a68-b0c9-6e8a3f5d2c17",
  "display_name": "BIM server",
  "certificate_fingerprint": null,
  "not_before": null,
  "not_after": null,
  "previous_fingerprint": null,
  "previous_fingerprint_valid_until": null,
  "status": "pending",
  "version": 0,
  "product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
  "control_role_id": "2e7f9a04-5c1b-4d83-a6f2-0b9c8d7e1a35",
  "role_id": "c58a1d63-9f02-47be-8e14-3a6d5b0f7c29"
}
  • 产品必须是 active。
  • 服务账号带 Control 的 product-runtime 角色,只能调验证登录状态这一类接口,不能调管理接口。
  • 返回的 status 是 pending,登记证书后再启用。
1.5

生成私钥和证书请求

在哪里做:产品的服务器

跑一条 openssl 命令,得到私钥 bim-service-key.pem 和证书请求 bim-service.csr。证书请求拿去签发;私钥留在产品的服务器上,不发给任何人。

CERT_DIR="$HOME/bes-certs/bim"
mkdir -p "$CERT_DIR"
SERVICE_ACCOUNT_ID=9d2c4b7e-1f35-4a68-b0c9-6e8a3f5d2c17   # 换成上一步返回的 service_account_id
openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
  -subj "/CN=BIM server" \
  -addext "subjectAltName=URI:urn:bes:service:$SERVICE_ACCOUNT_ID" \
  -addext "basicConstraints=critical,CA:FALSE" \
  -addext "keyUsage=critical,digitalSignature" \
  -addext "extendedKeyUsage=clientAuth" \
  -keyout "$CERT_DIR/bim-service-key.pem" -out "$CERT_DIR/bim-service.csr"
  • 证书唯一的 URI SAN 是 urn:bes:service:<service_account_id>。
  • 私钥 bim-service-key.pem 放进部署的密钥存储。
1.6

签发证书

在哪里做:客户端 CA 所在的机器

用客户端 CA 给证书请求签名,得到证书 bim-service.pem。证书拷回产品的服务器,和私钥一起用来调 Control。

前提:OpenSSL 3.0 及以上,openssl version 显示 OpenSSL 3.x。Mac 自带的 /usr/bin/openssl 是 LibreSSL,不认 -copy_extensions:先执行 brew install openssl@3,再执行 export PATH="$(brew --prefix openssl@3)/bin:$PATH"。

本环境(test)

  • CA_DIR:待定,test 环境的客户端 CA 位置未定。
  • 签发前把 bim-service.csr 拷到客户端 CA 所在机器的 ~/bes-certs/bim/;签发后把 bim-service.pem 拷回产品的服务器的同一目录,和私钥放在一起。
CERT_DIR="$HOME/bes-certs/bim"
mkdir -p "$CERT_DIR"
CA_DIR="待定"
openssl x509 -req -days 825 -sha256 -in "$CERT_DIR/bim-service.csr" \
  -CA "$CA_DIR/client-ca.pem" -CAkey "$CA_DIR/client-ca-key.pem" \
  -CAserial "$CERT_DIR/bim-service.srl" -CAcreateserial -copy_extensions copy \
  -out "$CERT_DIR/bim-service.pem"
其他环境

local

本地的客户端 CA 在本机,证书请求不用拷,在本机执行:

CERT_DIR="$HOME/bes-certs/bim"
mkdir -p "$CERT_DIR"
CA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/bes/local/stack/secrets/control"
openssl x509 -req -days 825 -sha256 -in "$CERT_DIR/bim-service.csr" \
  -CA "$CA_DIR/client-ca.pem" -CAkey "$CA_DIR/client-ca-key.pem" \
  -CAserial "$CERT_DIR/bim-service.srl" -CAcreateserial -copy_extensions copy \
  -out "$CERT_DIR/bim-service.pem"

staging

  • CA_DIR:待定,staging 环境的客户端 CA 位置未定。
  • 签发前把 bim-service.csr 拷到客户端 CA 所在机器的 ~/bes-certs/bim/;签发后把 bim-service.pem 拷回产品的服务器的同一目录,和私钥放在一起。
CERT_DIR="$HOME/bes-certs/bim"
mkdir -p "$CERT_DIR"
CA_DIR="待定"
openssl x509 -req -days 825 -sha256 -in "$CERT_DIR/bim-service.csr" \
  -CA "$CA_DIR/client-ca.pem" -CAkey "$CA_DIR/client-ca-key.pem" \
  -CAserial "$CERT_DIR/bim-service.srl" -CAcreateserial -copy_extensions copy \
  -out "$CERT_DIR/bim-service.pem"

prod

  • CA_DIR:待定,prod 环境的客户端 CA 位置未定。
  • 签发前把 bim-service.csr 拷到客户端 CA 所在机器的 ~/bes-certs/bim/;签发后把 bim-service.pem 拷回产品的服务器的同一目录,和私钥放在一起。
CERT_DIR="$HOME/bes-certs/bim"
mkdir -p "$CERT_DIR"
CA_DIR="待定"
openssl x509 -req -days 825 -sha256 -in "$CERT_DIR/bim-service.csr" \
  -CA "$CA_DIR/client-ca.pem" -CAkey "$CA_DIR/client-ca-key.pem" \
  -CAserial "$CERT_DIR/bim-service.srl" -CAcreateserial -copy_extensions copy \
  -out "$CERT_DIR/bim-service.pem"
1.7

算指纹和有效期

在哪里做:产品的服务器

从证书算出指纹和起止时间,得到三个值。下一步登记证书时填进 certificate_fingerprint、certificate_not_before、certificate_expires_at。

CERT_DIR="$HOME/bes-certs/bim"
openssl x509 -in "$CERT_DIR/bim-service.pem" -outform DER | openssl dgst -sha256 | awk '{print $NF}'
openssl x509 -in "$CERT_DIR/bim-service.pem" -noout -startdate -enddate
  • 第一行输出是 certificate_fingerprint:证书 DER 编码的 SHA-256,64 位十六进制。
  • notBefore= 后的时间是 certificate_not_before,notAfter= 后的是 certificate_expires_at,改写成 RFC 3339 UTC,例如 Jan 1 08:00:00 2026 GMT 写成 2026-01-01T08:00:00Z。
1.8

登记证书

在哪里做:测试台

把指纹和有效期登记到服务账号上,得到登记了证书的账号。之后 Control 认得这张证书。

POSTService Account Register

在测试台点本步的 Service Account Register 标签,按下面的值填表(id 用前面步骤返回的),点 Send request。

填表的值:

{
  "service_account_id": "9d2c4b7e-1f35-4a68-b0c9-6e8a3f5d2c17",
  "request_ref": "register-bim-server-certificate",
  "product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
  "display_name": "BIM server",
  "certificate_fingerprint": "<certificate_fingerprint>",
  "certificate_not_before": "2026-01-01T08:00:00Z",
  "certificate_expires_at": "2028-04-05T08:00:00Z"
}

返回:

{
  "service_account_id": "9d2c4b7e-1f35-4a68-b0c9-6e8a3f5d2c17",
  "registered_client_id": "svc-9d2c4b7e-1f35-4a68-b0c9-6e8a3f5d2c17",
  "display_name": "BIM server",
  "certificate_fingerprint": "<certificate_fingerprint>",
  "not_before": "2026-01-01T08:00:00+00:00",
  "not_after": "2028-04-05T08:00:00+00:00",
  "previous_fingerprint": null,
  "previous_fingerprint_valid_until": null,
  "status": "pending",
  "version": 1,
  "product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
  "control_role_id": "2e7f9a04-5c1b-4d83-a6f2-0b9c8d7e1a35",
  "tenant_id": null,
  "project_id": null,
  "role_id": "c58a1d63-9f02-47be-8e14-3a6d5b0f7c29"
}
  • product_id、display_name 与创建服务账号时相同。
  • certificate_fingerprint、certificate_not_before、certificate_expires_at 取上一步算出的值。

错误:

HTTP 错误码 处理
400 CERTIFICATE_FINGERPRINT_INVALID 指纹要 64 位十六进制,重新计算。
409 CERTIFICATE_FINGERPRINT_CONFLICT 这张证书已登记给别的账号,重新生成证书。
1.9

启用服务账号

在哪里做:测试台

把服务账号改成 active,得到可用的服务账号。程序从此能带证书调 Control。

POSTService Account Set Status

在测试台点本步的 Service Account Set Status 标签,按下面的值填表(id 用前面步骤返回的),点 Send request。

填表的值:

{
  "service_account_id": "9d2c4b7e-1f35-4a68-b0c9-6e8a3f5d2c17",
  "request_ref": "activate-bim-server-account",
  "product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
  "status": "active"
}

返回:

{
  "service_account_id": "9d2c4b7e-1f35-4a68-b0c9-6e8a3f5d2c17",
  "registered_client_id": "svc-9d2c4b7e-1f35-4a68-b0c9-6e8a3f5d2c17",
  "display_name": "BIM server",
  "certificate_fingerprint": "<certificate_fingerprint>",
  "not_before": "2026-01-01T08:00:00+00:00",
  "not_after": "2028-04-05T08:00:00+00:00",
  "previous_fingerprint": null,
  "previous_fingerprint_valid_until": null,
  "status": "active",
  "version": 2,
  "product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
  "control_role_id": "2e7f9a04-5c1b-4d83-a6f2-0b9c8d7e1a35",
  "tenant_id": null,
  "project_id": null,
  "role_id": "c58a1d63-9f02-47be-8e14-3a6d5b0f7c29"
}
  • 没登记证书或证书已过期时不能启用。
  • status: disabled 停用账号;停用的账号不能再启用,要新建一个。

做完这一部分,手里有:product_id、client_secret、证书和私钥。client_secret 让用户能登录,证书和私钥用来调 Control。

2. 登记功能(feature)和角色
2.1

登记功能

在哪里做:测试台

把程序的全部路由登记成功能,得到功能清单。验证请求时 Control 按它找到功能码。

PUTFeature Register

在测试台点本步的 Feature Register 标签,按下面的值填表(id 用前面步骤返回的),点 Send request。

填表的值:

{
  "product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
  "deployment_version": "v1",
  "request_ref": "register-bim-features-v1",
  "features": [
    {
      "code": "document.read",
      "route_template": "/documents/{id}",
      "method": "GET",
      "risk": "low",
      "display_name": "Read documents"
    },
    {
      "code": "document.write",
      "route_template": "/documents",
      "method": "POST",
      "risk": "low",
      "display_name": "Write documents"
    },
    {
      "code": "payment.approve",
      "route_template": "/payments/{id}/approve",
      "method": "POST",
      "risk": "high",
      "display_name": "Approve payments"
    }
  ]
}

返回:

[
  {
    "feature_id": "0a5d8e3c-9b14-4f72-a6c1-e3b7d2f05948",
    "product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
    "code": "document.read",
    "route_template": "/documents/{id}",
    "method": "GET",
    "risk": "low",
    "display_name": "Read documents",
    "deployment_version": "v1",
    "status": "active"
  },
  {
    "feature_id": "1b6e9f4d-a025-4083-b7d2-f4c8e3a16a59",
    "product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
    "code": "document.write",
    "route_template": "/documents",
    "method": "POST",
    "risk": "low",
    "display_name": "Write documents",
    "deployment_version": "v1",
    "status": "active"
  },
  {
    "feature_id": "2c7fa05e-b136-4194-88e3-05d9f4b27b6a",
    "product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
    "code": "payment.approve",
    "route_template": "/payments/{id}/approve",
    "method": "POST",
    "risk": "high",
    "display_name": "Approve payments",
    "deployment_version": "v1",
    "status": "active"
  }
]
  • 一个功能是一组 method + route_template,code 登记后不再改。
  • 每次调用替换这个 deployment_version 的整份清单,所以每次都发全部功能。
  • risk: high 的路由,权限结果不走缓存。
  • 程序验证时发的 deployment_version 下没有的路由,返回 TARGET_UNKNOWN。
2.2

定义角色

在哪里做:测试台

把几个功能码组成一个角色,得到 role_id。邀请人、分配角色时填它。

PUTRole Define

在测试台点本步的 Role Define 标签,按下面的值填表(id 用前面步骤返回的),点 Send request。

填表的值:

{
  "product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
  "code": "document-controller",
  "request_ref": "define-document-controller",
  "display_name": "Document controller",
  "feature_codes": [
    "document.read",
    "document.write"
  ]
}

返回:

{
  "role_id": "5c2f8a1d-6e93-4b07-8c4a-d1e6f3b92a75",
  "product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
  "code": "document-controller",
  "display_name": "Document controller",
  "scope_level": "project",
  "feature_codes": [
    "document.read",
    "document.write"
  ],
  "status": "active",
  "tenant_id": null,
  "project_id": null,
  "version": 0
}
  • 按 code 创建或整体替换一个角色。
  • feature_codes 只能写本产品已登记且 active 的功能码。

错误:

HTTP 错误码 处理
400 ROLE_CONFIGURATION_INVALID feature_codes 有没登记的功能码,先登记功能。

做完这一部分,手里有:功能清单和 role_id。邀请人、给人分配角色时填 role_id。

3. 带着凭据调接口
3.1

用证书调第一个接口

在哪里做:产品的服务器

用证书和私钥发一次不带用户令牌的验证请求,得到 403。说明证书和服务账号已被认出,之后验证请求都带这张证书。

GETGateway Validate
  • 返回 403,响应头 X-BES-Auth-Reason: access_denied,没有响应体:证书已通过(Control 认过证书后,因为没有用户令牌和租户而拒绝)。
  • 返回 401,没有响应体(Control 的错误码 CALLER_AUTHENTICATION_FAILED):证书不是登记的那张,或服务账号没启用。
  • 地址:带证书的接口发到 https://id.test.globalbes.sg:9443,只带用户令牌的接口发到 https://id.test.globalbes.sg。
  • 证书:TLS 客户端证书 bim-service.pem、私钥 bim-service-key.pem,用服务端 CA 验证 Control 的证书。
  • 写操作:请求体带 request_ref,同一个 request_ref 重发返回第一次的结果。
  • X-BES-Operator:可选,写代表哪个人操作的 person_id。
  • 出错时返回 {"error": {"code": …, "reference": …, "message": …}},程序按 code 分支。

本环境(test)

  • SERVER_CA:待定,test 环境验证 Control 服务端证书的 CA 文件未定。

在产品的服务器上执行:

CERT_DIR="$HOME/bes-certs/bim"
PRODUCT_ID=b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614   # 换成创建产品时返回的 product_id
SERVER_CA="待定"
curl -i --cert "$CERT_DIR/bim-service.pem" --key "$CERT_DIR/bim-service-key.pem" \
  --cacert "$SERVER_CA" \
  -H "X-BES-Product: $PRODUCT_ID" \
  -H "X-Original-Method: GET" \
  -H "X-Original-URI: /documents/42" \
  -H "X-BES-Deployment-Version: v1" \
  "https://id.test.globalbes.sg:9443/internal/auth/validate"
其他环境

local

在本机执行:

CERT_DIR="$HOME/bes-certs/bim"
PRODUCT_ID=b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614   # 换成创建产品时返回的 product_id
SERVER_CA="${XDG_DATA_HOME:-$HOME/.local/share}/bes/local/stack/secrets/stack/server-ca.pem"
curl -i --cert "$CERT_DIR/bim-service.pem" --key "$CERT_DIR/bim-service-key.pem" \
  --cacert "$SERVER_CA" \
  -H "X-BES-Product: $PRODUCT_ID" \
  -H "X-Original-Method: GET" \
  -H "X-Original-URI: /documents/42" \
  -H "X-BES-Deployment-Version: v1" \
  "https://id.local.globalbes.sg:19443/internal/auth/validate"

staging

  • SERVER_CA:待定,staging 环境验证 Control 服务端证书的 CA 文件未定。

在产品的服务器上执行:

CERT_DIR="$HOME/bes-certs/bim"
PRODUCT_ID=b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614   # 换成创建产品时返回的 product_id
SERVER_CA="待定"
curl -i --cert "$CERT_DIR/bim-service.pem" --key "$CERT_DIR/bim-service-key.pem" \
  --cacert "$SERVER_CA" \
  -H "X-BES-Product: $PRODUCT_ID" \
  -H "X-Original-Method: GET" \
  -H "X-Original-URI: /documents/42" \
  -H "X-BES-Deployment-Version: v1" \
  "待定/internal/auth/validate"

prod

  • SERVER_CA:待定,prod 环境验证 Control 服务端证书的 CA 文件未定。

在产品的服务器上执行:

CERT_DIR="$HOME/bes-certs/bim"
PRODUCT_ID=b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614   # 换成创建产品时返回的 product_id
SERVER_CA="待定"
curl -i --cert "$CERT_DIR/bim-service.pem" --key "$CERT_DIR/bim-service-key.pem" \
  --cacert "$SERVER_CA" \
  -H "X-BES-Product: $PRODUCT_ID" \
  -H "X-Original-Method: GET" \
  -H "X-Original-URI: /documents/42" \
  -H "X-BES-Deployment-Version: v1" \
  "待定/internal/auth/validate"

做完这一部分,手里有:确认证书已被 Control 认出,之后验证请求都带这张证书。

4. 验证登录状态
4.1

把用户跳到登录页

在哪里做:产品代码

用户没登录时,程序把浏览器跳到 BES 登录页。登录完,浏览器带着 code 回到程序,下一步用它换令牌。

GETAuthorize (sign-in page)
GET https://id.test.globalbes.sg/authorize?response_type=code&client_id=bim-web&redirect_uri=https%3A%2F%2Fbim.example.com%2Flogin%2Foauth2%2Fcode%2Fbes&scope=openid%20profile%20email&state=<state>&nonce=<nonce>&code_challenge=<code_challenge>&code_challenge_method=S256
  • 端点地址从 GET https://id.test.globalbes.sg/.well-known/openid-configuration 读。
  • state、nonce 每次随机生成,存在用户会话里。
  • code_challenge = BASE64URL(SHA256(code_verifier)),只支持 S256;以上参数缺一个就被拒。
  • 回调收到 ?code=…&state=…;code 60 秒内有效,只能用一次。
Java(Spring Boot)
spring:
  security:
    oauth2:
      client:
        registration:
          bes:
            client-id: ${BES_CLIENT_ID}
            client-secret: ${BES_CLIENT_SECRET}
            authorization-grant-type: authorization_code
            client-authentication-method: client_secret_basic
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            scope: openid, profile, email
        provider:
          bes:
            issuer-uri: ${BES_ISSUER}
@Configuration
@EnableWebSecurity
class SecurityConfig {
    @Bean
    SecurityFilterChain security(HttpSecurity http, ClientRegistrationRepository clients) throws Exception {
        // BES requires PKCE from every client; Spring only adds it for public clients by default.
        var resolver = new DefaultOAuth2AuthorizationRequestResolver(clients, "/oauth2/authorization");
        resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce());
        http.authorizeHttpRequests(a -> a.anyRequest().authenticated())
            .oauth2Login(o -> o.authorizationEndpoint(e -> e.authorizationRequestResolver(resolver)))
            .oauth2Client(Customizer.withDefaults());
        return http.build();
    }
}
4.2

用 code 换令牌

在哪里做:产品代码

用 code 和 client_secret 换令牌,得到 access_token 和 refresh_token。存在服务端会话里,之后每个请求和刷新都要用。

POSTToken
POST https://id.test.globalbes.sg/oauth/token
Authorization: Basic <base64(client_id:client_secret)>
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&code=<code>&code_verifier=<code_verifier>&redirect_uri=https%3A%2F%2Fbim.example.com%2Flogin%2Foauth2%2Fcode%2Fbes
HTTP/1.1 200 OK

{
  "access_token": "<access_token>",
  "id_token": "<id_token>",
  "refresh_token": "<refresh_token>",
  "token_type": "Bearer",
  "expires_in": 300,
  "scope": "openid profile email"
}
  • access_token、refresh_token:存在服务端的用户会话里,浏览器只拿会话 cookie。
  • access_token 有效 5 分钟。
  • id_token 里的 sub 是这个人的 person_id,长期不变,用作关联自己用户数据的键。
4.3

让用户选租户

在哪里做:产品代码

带用户的 access_token 发起选租户,用户选好后程序得到 context_id。存进会话,每次验证请求时带上。

POSTSession Tenant Selection Start

程序里发这个请求(id 用前面步骤返回的):

带:证书 + 用户的 access_token

请求:

POST https://id.test.globalbes.sg:9443/api/sessions/tenant-selection
Authorization: Bearer <access_token>
Content-Type: application/json

{
  "continue_url": "https://bim.example.com/tenant/chosen"
}

返回:

HTTP/1.1 201 Created

{
  "selection_id": "9c4e1b7a-2d58-4f3c-b6a0-e81d5f2c7a94",
  "selection_url": "/tenants?selection=9c4e1b7a-2d58-4f3c-b6a0-e81d5f2c7a94",
  "expires_at": "2026-01-01T08:05:00+00:00"
}
  • continue_url 必须与登录客户端登记的 redirect_uris 或 post_logout_redirect_uris 之一完全一致。
  • 把浏览器跳到 https://id.test.globalbes.sg + selection_url。
  • 用户选好租户后,浏览器回到 https://bim.example.com/tenant/chosen?context_id=0f6b8d2e-4a17-4c93-9e5b-a1d27c4f3b68;把 context_id 按 UUID 解析后存进服务端会话。
  • 回调带 error=login_required 时,重新登录再发起选择。
  • 选择 5 分钟内有效;同一会话再发起一次会取消上一次。
  • 切换租户:再调一次这个接口。

错误:

HTTP 错误码 处理
401 LOGIN_REQUIRED BES 登录已结束,重新登录后再发起。
Java(Spring Boot)
spring:
  ssl:
    bundle:
      pem:
        bes-control:
          keystore:
            certificate: file:/run/secrets/bes-service-cert.pem
            private-key: file:/run/secrets/bes-service-key.pem
          truststore:
            certificate: file:/run/secrets/bes-gateway-ca.pem
bes:
  issuer: ${BES_ISSUER}
  control-gateway: ${BES_CONTROL_GATEWAY}   # the mTLS address of Control, not the issuer
@Bean
RestClient besControl(RestClient.Builder builder, SslBundles bundles,
                      @Value("${bes.control-gateway}") String gateway) {
    var settings = ClientHttpRequestFactorySettings.ofSslBundle(bundles.getBundle("bes-control"));
    return builder.baseUrl(gateway)
        .requestFactory(ClientHttpRequestFactoryBuilder.detect().build(settings))
        .build();
}

record SelectionStart(String continue_url) {}
record Selection(String selection_id, String selection_url, String expires_at) {}

String startTenantSelection(String accessToken) {
    Selection selection = besControl.post().uri("/api/sessions/tenant-selection")
        .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
        .body(new SelectionStart(appBaseUrl + "/tenant/chosen"))
        .retrieve().body(Selection.class);
    return issuer + selection.selection_url();   // redirect the browser here
}

@GetMapping("/tenant/chosen")
String tenantChosen(@RequestParam("context_id") UUID contextId, HttpSession session) {
    session.setAttribute("bes.context_id", contextId.toString());
    return "redirect:/";
}
4.4

验证每个请求

在哪里做:产品代码

之后用户每发一个请求,程序带证书、access_token、context_id 问 Control,返回这个人的权限列表。程序按它放行或拒绝。

POSTSession Validate

程序里发这个请求(id 用前面步骤返回的):

带:证书 + 用户的 access_token

请求:

POST https://id.test.globalbes.sg:9443/api/sessions/validate
Authorization: Bearer <access_token>
Content-Type: application/json

{
  "context_id": "0f6b8d2e-4a17-4c93-9e5b-a1d27c4f3b68",
  "target": {
    "method": "GET",
    "path": "/documents/42",
    "deployment_version": "v1"
  }
}

返回:

HTTP/1.1 200 OK

{
  "context": "<signed bes-context+jwt>",
  "checked_at": "2026-01-01T08:00:00.123456+00:00",
  "hard_expiry": "2026-01-01T08:00:30.123456+00:00",
  "action": "document.read",
  "risk": "low"
}
  • channel_assurance 说明身份怎么建立;是否按它限制动作由产品决定。
  • 每个受保护的请求在业务代码之前调一次。
  • target.method、target.path:程序收到的请求,不含查询串;deployment_version:登记功能清单时用的版本。
  • 有 context_id 时发 context_id;没有时改发 target.tenant_id(项目级产品再加 target.project_id),两者不同时发。
  • 平台级服务账号还要发 product_id。
  • context 是 Control 签名的 JWT(ES256,类型 bes-context+jwt),用 https://id.test.globalbes.sg/.well-known/jwks.json 验证,aud 是本产品的 product_id。
  • context 的 claim:sub(person_id)、tenant_id、project_id、action(本次请求对应的功能码)、risk、features(此人在这个范围的全部功能码)。
  • 资源是否属于 tenant_id / project_id,程序自己核对。
  • 同一用户、同一目标和动作可复用 context 到 hard_expiry(低风险 30 秒,高风险 5 秒)。
  • Control 不可达、超时或返回 5xx 时拒绝请求。

错误:

HTTP 错误码 处理
401 INVALID_TOKEN 刷新令牌后重试;刷新失败就重新登录。
401 LOGIN_REQUIRED 重新登录。
403 ACCESS_DENIED 返回 403。
403 ENTITLEMENT_MISSING 返回 403:租户没有有效的开通记录。
403 TARGET_UNKNOWN 把这条路由登记进这个版本的功能清单。
Java(Spring Boot)
record Target(String method, String path, String deployment_version) {}
record ValidateRequest(String context_id, Target target) {}
record Validated(String context, String checked_at, String hard_expiry, String action, String risk) {}

@Component
class BesAuthorizationFilter extends OncePerRequestFilter {
    private final RestClient besControl;
    private final ConfigurableJWTProcessor<SecurityContext> contexts;   // see below
    // constructor omitted

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String accessToken = currentAccessToken(request);          // from the OAuth2AuthorizedClient
        String contextId = (String) request.getSession().getAttribute("bes.context_id");
        try {
            Validated validated = besControl.post().uri("/api/sessions/validate")
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
                .body(new ValidateRequest(contextId,
                      new Target(request.getMethod(), request.getRequestURI(), "v1")))
                .retrieve().body(Validated.class);
            JWTClaimsSet claims = contexts.process(validated.context(), null);
            request.setAttribute("bes.claims", claims);
            chain.doFilter(request, response);
        } catch (HttpClientErrorException.Unauthorized e) {
            response.sendRedirect("/oauth2/authorization/bes");     // sign in again
        } catch (HttpClientErrorException e) {
            response.sendError(HttpServletResponse.SC_FORBIDDEN);   // fail closed
        } catch (Exception e) {
            response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
        }
    }
}

@Bean
ConfigurableJWTProcessor<SecurityContext> besContexts(@Value("${bes.issuer}") String issuer,
                                                      @Value("${bes.product-id}") String productId)
        throws MalformedURLException {
    var processor = new DefaultJWTProcessor<SecurityContext>();
    processor.setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType("bes-context+jwt")));
    processor.setJWSKeySelector(new JWSVerificationKeySelector<>(JWSAlgorithm.ES256,
        JWKSourceBuilder.create(new URL(issuer + "/.well-known/jwks.json")).build()));
    processor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(productId,
        new JWTClaimsSet.Builder().issuer(issuer).build(), Set.of("sub", "exp", "features")));
    return processor;
}
boolean canWrite = claims.getStringListClaim("features").contains("document.write");
nginx(gateway.validate)

nginx 用 auth_request 调 gateway.validate,产品从 X-BES-Context 读签名的 context,验证方法同上。Control 返回 200 和 X-BES-Context、401(重新登录)或带 X-BES-Auth-Reason(access_denied、target_unknown、entitlement_missing)的 403;其他状态和超时一律拒绝。

location = /_bes_validate {
    internal;
    proxy_pass ${CONTROL_INTERNAL_ORIGIN}/internal/auth/validate;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
    proxy_set_header Cookie "";
    proxy_set_header Authorization $http_authorization;
    proxy_set_header X-BES-TLS-Verified "";
    proxy_set_header X-BES-Client-Cert "";
    proxy_set_header X-BES-Operator "";
    proxy_set_header X-BES-Product "${PRODUCT_ID}";
    proxy_set_header X-BES-Context-Id $cookie_bes_context;
    proxy_set_header X-BES-Tenant "";
    proxy_set_header X-BES-Project "";
    proxy_set_header X-Original-Method $request_method;
    proxy_set_header X-Original-URI $request_uri;
    proxy_set_header X-Original-Host $host;
    proxy_set_header X-BES-Deployment-Version v1;
    proxy_ssl_server_name on;
    proxy_ssl_name ${CONTROL_HOST};
    proxy_ssl_verify on;
    proxy_ssl_trusted_certificate /run/secrets/control-ca;
    proxy_ssl_certificate /run/secrets/gateway-cert;
    proxy_ssl_certificate_key /run/secrets/gateway-key;
}
location / {
    auth_request /_bes_validate;
    auth_request_set $bes_context $upstream_http_x_bes_context;
    proxy_set_header X-BES-Context $bes_context;
    proxy_pass ${PRODUCT_UPSTREAM};
}
4.5

刷新令牌

在哪里做:产品代码

access_token 过期前用 refresh_token 换一对新令牌。两个都存下,下次请求和刷新用新的。

POSTToken
POST https://id.test.globalbes.sg/oauth/token
Authorization: Basic <base64(client_id:client_secret)>
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&refresh_token=<refresh_token>
  • 返回新的 access_token 和 refresh_token,两个都存下。
  • 每个 refresh_token 只能用一次;旧的再用一次,整个会话被撤销。同一会话的刷新要串行。
  • 网页会话空闲 2 小时或总共 12 小时后结束,刷新也不延长。

做完这一部分,手里有:用户登录后的令牌和 context_id,每个请求都有 Control 签名的权限列表。

5. 高风险操作再确认
5.1

发起再确认

在哪里做:产品代码

用户要做高风险动作时,程序发起再确认,得到 challenge_id 和确认页地址。把浏览器跳过去让用户再确认身份。

POSTStep-up Request

程序里发这个请求(id 用前面步骤返回的):

带:证书 + 用户的 access_token

请求:

POST https://id.test.globalbes.sg:9443/api/sessions/step-up
Authorization: Bearer <access_token>
Content-Type: application/json

{
  "request_ref": "payment-7731-approve",
  "purpose": "remote_action",
  "action_digest": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=",
  "context_id": "0f6b8d2e-4a17-4c93-9e5b-a1d27c4f3b68"
}

返回:

HTTP/1.1 201 Created

{
  "challenge_id": "d3a61f08-9b2c-4e75-8f04-6c1e7a5b2d39",
  "completion_url": "/step-up?challenge=d3a61f08-9b2c-4e75-8f04-6c1e7a5b2d39",
  "expires_at": "2026-01-01T08:05:00+00:00"
}
  • action_digest:动作规范描述的 SHA-256,标准 base64,例如对 approve-payment|7731|12500.00|SGD 计算。
  • request_ref:原请求的编号;同一个 request_ref 重发返回同一个 challenge。
  • 把浏览器跳到 https://id.test.globalbes.sg + completion_url;确认页不跳回,程序页面保持打开,用户回来后点「继续」。
5.2

换取许可

在哪里做:产品代码

用户确认后,程序用 challenge_id 换许可,得到 5 秒内有效的 permit_id。在执行动作的同一个事务里核对它。

POSTStep-up Consume

程序里发这个请求(id 用前面步骤返回的):

带:证书 + 用户的 access_token

请求:

POST https://id.test.globalbes.sg:9443/api/sessions/step-up/consume
Authorization: Bearer <access_token>
Content-Type: application/json

{
  "request_ref": "payment-7731-approve",
  "challenge_id": "d3a61f08-9b2c-4e75-8f04-6c1e7a5b2d39",
  "action_digest": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg="
}

返回:

HTTP/1.1 200 OK

{
  "permit_id": "a8f2c6d1-5e39-4b07-9d4c-2f7b1e6a3c85",
  "challenge_id": "d3a61f08-9b2c-4e75-8f04-6c1e7a5b2d39",
  "purpose": "remote_action",
  "action_digest": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=",
  "assurance": "password",
  "amr": [
    "pwd"
  ],
  "authenticated_at": "2026-01-01T08:01:10+00:00",
  "issued_at": "2026-01-01T08:01:12+00:00",
  "expires_at": "2026-01-01T08:01:17+00:00"
}
  • request_ref、action_digest 与发起时相同;action_digest 按即将修改的数据重新计算。
  • 紧挨着执行动作之前调用。
  • 在执行动作的同一个数据库事务里:核对 expires_at 未到、action_digest 等于本次动作的摘要,把 permit_id 插进有唯一约束的表,一个许可只做一次动作。
  • 重复调用返回同一个许可。

错误:

HTTP 错误码 处理
403 STEP_UP_INVALID 值与发起时不一致:重新发起再确认。
410 STEP_UP_EXPIRED 重新发起再确认。
Java(Spring Boot)
record StepUpRequest(String request_ref, String purpose, String action_digest, String context_id) {}
record Challenge(String challenge_id, String completion_url, String expires_at) {}
record StepUpConsume(String request_ref, String challenge_id, String action_digest) {}
record Permit(String permit_id, String action_digest, String assurance, String expires_at) {}

String digest(String canonicalAction) throws NoSuchAlgorithmException {
    byte[] hash = MessageDigest.getInstance("SHA-256").digest(canonicalAction.getBytes(StandardCharsets.UTF_8));
    return Base64.getEncoder().encodeToString(hash);
}

Permit consume(String accessToken, String requestRef, String challengeId, String digest) {
    return besControl.post().uri("/api/sessions/step-up/consume")
        .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
        .body(new StepUpConsume(requestRef, challengeId, digest))
        .retrieve().body(Permit.class);
}

做完这一部分,手里有:一个 5 秒内有效、只能用一次的 permit_id,执行这一个高风险动作时核对。

6. 登出
6.1

结束登录

在哪里做:产品代码

用户退出时,程序带 access_token 结束在 Control 里的登录,手里的令牌失效。接着结束浏览器里的登录。

POSTSession Logout

程序里发这个请求(id 用前面步骤返回的):

带:用户的 access_token

请求:

POST https://id.test.globalbes.sg/api/sessions/logout
Authorization: Bearer <access_token>
Content-Type: application/json

{
  "login_scope": "product"
}

返回:

HTTP/1.1 204 No Content
  • login_scope: product(默认)登出本产品所有登录客户端,client 只登出这一个。
  • 先调这个接口,再清掉自己的会话。
  • access_token 过期时先刷新;刷新不了就直接做下一步。
  • 登录客户端登记了 backchannel_logout_uri 时,别处结束会话后 Control 向它 POST 签名的 logout_token(表单字段,claim sid);收到后结束这个 sid 的本地会话。
Java(Spring Boot)
@PostMapping("/logout")
String logout(HttpSession session, @RegisteredOAuth2AuthorizedClient("bes") OAuth2AuthorizedClient client) {
    RestClient.create(issuer).post().uri("/api/sessions/logout")
        .header(HttpHeaders.AUTHORIZATION, "Bearer " + client.getAccessToken().getTokenValue())
        .body(Map.of("login_scope", "product"))
        .retrieve().toBodilessEntity();
    session.invalidate();
    return "redirect:" + issuer + "/end_session?client_id=" + clientId
        + "&post_logout_redirect_uri=" + URLEncoder.encode(appBaseUrl + "/", StandardCharsets.UTF_8)
        + "&state=" + UUID.randomUUID();
}
6.2

结束浏览器登录

在哪里做:产品代码

把浏览器跳到 BES 的退出地址,结束 BES 在浏览器里的登录,然后回到程序的首页。

GETEnd session
GET https://id.test.globalbes.sg/end_session?client_id=bim-web&post_logout_redirect_uri=https%3A%2F%2Fbim.example.com%2F&state=<state>
  • BES 显示确认页,然后回到 post_logout_redirect_uri;这个地址必须登记在登录客户端上,逐字符一致。

做完这一部分,手里有:Control 和浏览器里的登录都已结束,手里的令牌失效。

7. 接收事件
7.1

登记接收地址

在哪里做:Control 服务器

在 Control 的部署配置里写上程序的接收地址和要收的事件,得到一个登记的接收方。Control 之后把事件发到这个地址。

没有接口:写进 Control 部署配置的 event_consumers(环境变量 BES_CONTROL_EVENT_CONSUMERS),JSON 对象,键是接收方名字:

{
  "bim": {
    "endpoint": "https://bim.example.com/bes/events",
    "audience": "bim-api",
    "event_types": [
      "membership.revoked",
      "role_assignment.changed"
    ]
  }
}
  • event_types 取契约仓 events/control/ 下的事件名:membership.revoked、role.changed、role_assignment.changed、entitlement.changed、person.status_changed 等。

本环境(test)

  • Control 部署配置的位置和改完后怎样生效:待定,test 环境的 Control 部署方式未定。
其他环境

local

本地 Control 的配置在 bes-infra 仓 envs/local/compose.yaml:BES_CONTROL_EVENT_CONSUMERS 里已有 ./envs/local/dev stack up 配好的接收方 event-consumer-stub。用编辑器把 bim 加进同一个 JSON 对象,再起一次栈让 Control 读到新配置:

BES_INFRA="$HOME/Projects/bes-infra"   # bes-infra 仓的路径
"$BES_INFRA/envs/local/dev" stack up
  • 本地 Control 只信任栈的服务端 CA(~/.local/share/bes/local/stack/secrets/stack/server-ca.pem),接收地址要能从栈的容器里访问,证书由这张 CA 签发。

staging

  • Control 部署配置的位置和改完后怎样生效:待定,staging 环境的 Control 部署方式未定。

prod

  • Control 部署配置的位置和改完后怎样生效:待定,prod 环境的 Control 部署方式未定。
7.2

验证收到的事件

在哪里做:产品代码

程序收到事件后验证签名,按 event_id 存下再回 2xx,得到自己表里的事件。之后从表里处理。

Control 每个事件发一个请求:

POST https://bim.example.com/bes/events
Content-Type: application/jwt
Idempotency-Key: 3e9a7c51-0b46-4d2f-9e83-a1c5f7d2b064

<event_jws>
  • 请求体是 ES256 签名的 JWT,类型 bes-event+jwt,用 https://id.test.globalbes.sg/.well-known/jwks.json 验证;aud 是登记的 audience,5 分钟后过期。
  • claim 是 common/event-envelope.json 的信封:event_id、event_type、aggregate_type、aggregate_id、tenant_id、product_id、sequence、occurred_at、trace_id、payload。
  • 先按 event_id(主键)存下再回 2xx,之后从自己的表里处理;重复投递按 event_id 去重。
  • 同一接收方的事件严格按顺序到达。没回 2xx 时 Control 从 30 秒起重试到 24 小时、共 10 次;之后这次投递保持失败,后面的事件都停住。
Java(Spring Boot)
@PostMapping(path = "/bes/events", consumes = "application/jwt")
ResponseEntity<Void> receive(@RequestBody String jws) throws Exception {
    JWTClaimsSet event = events.process(jws, null);      // same setup as the context processor,
                                                         // type "bes-event+jwt", audience "my-product-api"
    try {
        jdbc.update("insert into bes_event_inbox(event_id, event_type, body) values (?, ?, ?::jsonb)",
            event.getStringClaim("event_id"), event.getStringClaim("event_type"), event.toString());
    } catch (DuplicateKeyException alreadyStored) {
        // delivered before: acknowledge again
    }
    return ResponseEntity.noContent().build();
}

做完这一部分,手里有:Control 发来的事件存进程序自己的表,按 event_id 去重后处理。

8. 账户设置入口
8.1

加「Account settings」入口

在哪里做:产品代码

在用户菜单里加「Account settings」链接,得到打开 BES 账户页的入口。用户在那里改密码、两步验证等设置。

  • 在产品的用户菜单里加「Account settings」链接,在新标签页或约 560×760 的小窗口打开 https://id.test.globalbes.sg/account。
  • 不能放进 iframe:BES 页面拒绝被嵌入。
  • 密码、两步验证、恢复码、已登录的浏览器、手机和邮箱、语言都在那里管理,产品不做这些页面。
  • 用户菜单只留:当前用户、切换公司(session.tenant-selection-start)、Account settings、登出(end_session)。

做完这一部分,手里有:用户菜单里的「Account settings」链接,打开 BES 的账户页。

9. 嵌入网页助手
9.1

登记助手访问目标

在哪里做:产品代码

在每个嵌挂件产品的完整功能目录里加入 assistant.chat,并授给允许使用助手的角色。

PUTFeature Register

使用 feature.register 提交完整目录,保留原有功能,并加入 {code: assistant.chat, method: POST, route_template: /assistant/chat, risk: low, display_name: Assistant chat}。deployment_version 必须与挂件 validate 的 target 一致。 为宿主登记唯一有效的产品服务客户端,配置其部署版本和可委托的业务 scopes。Assistant 委托 scope 不能超出此客户端登记值或人员当前功能权限。 Assistant 授权参数 host_product_id 指向本产品,用户须先登录本产品;普通产品调用 session.validate / tenant-list 不传 product_id。 产品退出关闭本产品的 product_login;Control 挂件绑定检查状态与 generation,因此同一 SSO 的重新登录也不能恢复旧挂件 sid。

做完这一部分,手里有:产品目录与角色允许用户使用助手,产品退出使挂件失效。

其他功能
功能:管理团队成员