Files
sub2api-cn-relay-manager/scripts/acceptance/verify_user_key_self_service.sh
phamnazage-jpg 4e2ee087fd feat(vNext.4): implement trusted-subject security chain for portal user key self-service
- Add portal_auth.go: Portal user session auth with HMAC-signed cookies
- Add /api/portal/session/{login,logout,state} endpoints
- Update nginx config template: cookie-to-header trusted proxy pattern
- Update frontend: sync CRM session on login/logout
- Add TRUSTED_SUBJECT_DEPLOY_GUIDE.md with remote43 deployment steps
- Update EXECUTION_BOARD.md: mark trusted-subject blocking issue as resolved

This implements the secure chain:
  Browser → Portal → nginx (cookie→header) → CRM (verify proxy secret)

Required remote43 actions:
1. Generate 64-char hex secret
2. Update .env.crm with TRUSTED_* config
3. Update nginx with cookie map and header injection
4. Restart services

Fixes EXECUTION_BOARD.md 2026-06-08 blocking issue
2026-06-09 07:48:03 +08:00

239 lines
8.4 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
TS="${TS:-$(date +%Y%m%d_%H%M%S)}"
ARTIFACT_DIR="${ARTIFACT_DIR:-$ROOT_DIR/artifacts/user-key-self-service/${TS}}"
CRM_BASE="${CRM_BASE:-https://sub.tksea.top/portal-admin-api}"
USER_CHAT_BASE="${USER_CHAT_BASE:-}"
CHAT_MODEL="${CHAT_MODEL:-gpt-5.4}"
USER_TRUSTED_SUBJECT_ID="${USER_TRUSTED_SUBJECT_ID:-}"
USER_TRUSTED_SUBJECT_HEADER="${USER_TRUSTED_SUBJECT_HEADER:-X-CRM-Authenticated-Subject}"
USER_TRUSTED_PROXY_SECRET_HEADER="${USER_TRUSTED_PROXY_SECRET_HEADER:-X-CRM-Trusted-Proxy}"
USER_TRUSTED_PROXY_SECRET="${USER_TRUSTED_PROXY_SECRET:-}"
mkdir -p "$ARTIFACT_DIR"
info() { printf 'INFO: %s\n' "$*"; }
ok() { printf 'OK: %s\n' "$*"; }
warn() { printf 'WARN: %s\n' "$*" >&2; }
die() { printf 'FATAL: %s\n' "$*" >&2; exit 1; }
usage() {
cat <<'EOF'
usage: verify_user_key_self_service.sh [--help|--env-check|--run]
Modes:
--help 显示帮助
--env-check 仅检查 CRM / chat 入口与认证前置
--run 执行真实 user-key 闭环create -> list -> get -> reset -> chat
Required env for --run:
CRM_BASE CRM API base, e.g. https://sub.tksea.top/portal-admin-api
USER_CHAT_BASE 最终 user-key 调用入口 base, e.g. https://sub.tksea.top
CHAT_MODEL chat 模型名default: gpt-5.4
Authentication for /api/keys endpoints:
USER_TRUSTED_SUBJECT_ID 受信代理注入的 subject 值
USER_TRUSTED_SUBJECT_HEADER subject 头名default: X-CRM-Authenticated-Subject
USER_TRUSTED_PROXY_SECRET_HEADER 代理密钥头名default: X-CRM-Trusted-Proxy
USER_TRUSTED_PROXY_SECRET 代理与 CRM 共享的密钥
Artifacts:
artifacts/user-key-self-service/<timestamp>/
- 00-env.json
- 10-create.headers.txt / 10-create.body.json
- 11-list.headers.txt / 11-list.body.json
- 12-get.headers.txt / 12-get.body.json
- 13-reset.headers.txt / 13-reset.body.json
- 20-chat.headers.txt / 20-chat.body.json
- 99-summary.json
EOF
}
build_auth_args() {
if [[ -n "$USER_TRUSTED_SUBJECT_ID" && -n "$USER_TRUSTED_PROXY_SECRET" ]]; then
printf '%s\n' \
"-H" "${USER_TRUSTED_SUBJECT_HEADER}: ${USER_TRUSTED_SUBJECT_ID}" \
"-H" "${USER_TRUSTED_PROXY_SECRET_HEADER}: ${USER_TRUSTED_PROXY_SECRET}"
return 0
fi
return 1
}
curl_json_with_capture() {
local method="$1"
local url="$2"
local headers_file="$3"
local body_file="$4"
local payload="${5:-}"
local -a args
args=(curl -sS --noproxy '*' -D "$headers_file" -o "$body_file" -X "$method")
while IFS= read -r line; do
args+=("$line")
done < <(build_auth_args)
if [[ -n "$payload" ]]; then
args+=(-H 'Content-Type: application/json' -d "$payload")
fi
args+=("$url")
"${args[@]}"
}
curl_chat_with_capture() {
local plaintext_key="$1"
local payload="$2"
local headers_file="$3"
local body_file="$4"
curl -sS --noproxy '*' \
-D "$headers_file" \
-o "$body_file" \
-H "Authorization: Bearer $plaintext_key" \
-H 'Content-Type: application/json' \
-X POST \
-d "$payload" \
"${USER_CHAT_BASE%/}/v1/chat/completions"
}
extract_http_code() {
local headers_file="$1"
awk 'toupper($1) ~ /^HTTP\// { code=$2 } END { print code }' "$headers_file"
}
json_get() {
local file="$1"
local expr="$2"
python3 - "$file" "$expr" <<'PY'
import json, sys
path, expr = sys.argv[1:3]
value = json.load(open(path, 'r', encoding='utf-8'))
for part in expr.split('.'):
if isinstance(value, dict):
value = value.get(part)
else:
raise SystemExit(2)
print("" if value is None else value)
PY
}
cmd_env_check() {
local crm_health="unreachable"
if crm_health=$(curl -sS --noproxy '*' "${CRM_BASE%/}/healthz" 2>/dev/null); then
:
else
crm_health="unreachable"
fi
local chat_health="unset"
if [[ -n "$USER_CHAT_BASE" ]]; then
if chat_health=$(curl -sS --noproxy '*' "${USER_CHAT_BASE%/}/healthz" 2>/dev/null); then
:
else
chat_health="unreachable"
fi
fi
OUT_PATH="$ARTIFACT_DIR/00-env.json" \
CRM_BASE_PY="$CRM_BASE" \
CRM_HEALTH="$crm_health" \
USER_CHAT_BASE_PY="$USER_CHAT_BASE" \
CHAT_HEALTH="$chat_health" \
HAS_TRUSTED_SUBJECT_ID="$USER_TRUSTED_SUBJECT_ID" \
HAS_TRUSTED_PROXY_SECRET="$USER_TRUSTED_PROXY_SECRET" \
python3 - <<'PY'
import json, os
out = {
"crm_base": os.environ["CRM_BASE_PY"],
"crm_health": os.environ["CRM_HEALTH"],
"user_chat_base": os.environ["USER_CHAT_BASE_PY"],
"user_chat_health": os.environ["CHAT_HEALTH"],
"has_trusted_subject_id": bool(os.environ["HAS_TRUSTED_SUBJECT_ID"]),
"has_trusted_proxy_secret": bool(os.environ["HAS_TRUSTED_PROXY_SECRET"]),
}
with open(os.environ["OUT_PATH"], "w", encoding="utf-8") as fh:
json.dump(out, fh, ensure_ascii=False, indent=2)
PY
if [[ "$crm_health" == "ok" ]]; then ok "CRM healthz=ok"; else warn "CRM healthz=$crm_health"; fi
if [[ -n "$USER_CHAT_BASE" ]]; then info "user chat health=$chat_health"; fi
ok "env summary: $ARTIFACT_DIR/00-env.json"
}
cmd_run() {
cmd_env_check
[[ -n "$USER_CHAT_BASE" ]] || die "USER_CHAT_BASE is required for --run"
if ! build_auth_args >/dev/null; then
die "set USER_TRUSTED_SUBJECT_ID and USER_TRUSTED_PROXY_SECRET for /api/keys authentication"
fi
local create_payload create_code key_id plaintext_key masked_preview create_body
create_payload='{"logical_group_id":"gpt-shared","display_name":"acceptance-key","allowed_models":["'"$CHAT_MODEL"'"]}'
curl_json_with_capture POST "${CRM_BASE%/}/api/keys" "$ARTIFACT_DIR/10-create.headers.txt" "$ARTIFACT_DIR/10-create.body.json" "$create_payload" >/dev/null
create_code="$(extract_http_code "$ARTIFACT_DIR/10-create.headers.txt")"
[[ "$create_code" == "201" ]] || die "create key failed: HTTP $create_code"
key_id="$(json_get "$ARTIFACT_DIR/10-create.body.json" 'key.key_id')"
plaintext_key="$(json_get "$ARTIFACT_DIR/10-create.body.json" 'plaintext_key')"
masked_preview="$(json_get "$ARTIFACT_DIR/10-create.body.json" 'key.masked_preview')"
[[ -n "$key_id" && -n "$plaintext_key" ]] || die "create key response missing key_id/plaintext_key"
ok "create key -> HTTP 201, key_id=$key_id"
curl_json_with_capture GET "${CRM_BASE%/}/api/keys" "$ARTIFACT_DIR/11-list.headers.txt" "$ARTIFACT_DIR/11-list.body.json" >/dev/null
[[ "$(extract_http_code "$ARTIFACT_DIR/11-list.headers.txt")" == "200" ]] || die "list keys failed"
ok "list keys -> HTTP 200"
curl_json_with_capture GET "${CRM_BASE%/}/api/keys/${key_id}" "$ARTIFACT_DIR/12-get.headers.txt" "$ARTIFACT_DIR/12-get.body.json" >/dev/null
[[ "$(extract_http_code "$ARTIFACT_DIR/12-get.headers.txt")" == "200" ]] || die "get key failed"
ok "get key -> HTTP 200"
curl_json_with_capture POST "${CRM_BASE%/}/api/keys/${key_id}/reset" "$ARTIFACT_DIR/13-reset.headers.txt" "$ARTIFACT_DIR/13-reset.body.json" '{}' >/dev/null
[[ "$(extract_http_code "$ARTIFACT_DIR/13-reset.headers.txt")" == "200" ]] || die "reset key failed"
plaintext_key="$(json_get "$ARTIFACT_DIR/13-reset.body.json" 'plaintext_key')"
masked_preview="$(json_get "$ARTIFACT_DIR/13-reset.body.json" 'masked_preview')"
[[ -n "$plaintext_key" && -n "$masked_preview" ]] || die "reset response missing plaintext_key/masked_preview"
ok "reset key -> HTTP 200"
local chat_payload chat_code
chat_payload='{"model":"'"$CHAT_MODEL"'","messages":[{"role":"user","content":"ping"}],"max_tokens":16,"temperature":0}'
curl_chat_with_capture "$plaintext_key" "$chat_payload" "$ARTIFACT_DIR/20-chat.headers.txt" "$ARTIFACT_DIR/20-chat.body.json" >/dev/null
chat_code="$(extract_http_code "$ARTIFACT_DIR/20-chat.headers.txt")"
[[ "$chat_code" == "200" ]] || die "user chat failed: HTTP $chat_code"
ok "user chat -> HTTP 200"
python3 - "$ARTIFACT_DIR/99-summary.json" <<PY
import json
summary = {
"crm_base": ${CRM_BASE@Q},
"user_chat_base": ${USER_CHAT_BASE@Q},
"chat_model": ${CHAT_MODEL@Q},
"key_id": ${key_id@Q},
"masked_preview": ${masked_preview@Q},
"create_http": int(${create_code@Q}),
"list_http": 200,
"get_http": 200,
"reset_http": 200,
"chat_http": 200,
"checks": {
"create_returns_plaintext_once": True,
"list_returns_200": True,
"get_returns_200": True,
"reset_returns_new_plaintext": True,
"user_chat_200": True,
}
}
json.dump(summary, open(${ARTIFACT_DIR@Q} + "/99-summary.json", "w"), ensure_ascii=False, indent=2)
PY
cat "$ARTIFACT_DIR/99-summary.json"
}
case "${1:---help}" in
--help|-h)
usage
;;
--env-check)
cmd_env_check
;;
--run)
cmd_run
;;
*)
usage
exit 1
;;
esac