diff --git a/Dockerfile b/Dockerfile index d07b76f..0cc8e28 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,13 @@ FROM gradle:9.6.1-jdk21-alpine AS build -RUN mkdir -p /workspace && chown gradle:gradle /workspace +RUN apk add --no-cache nodejs npm \ + && mkdir -p /workspace \ + && chown gradle:gradle /workspace WORKDIR /workspace COPY --chown=gradle:gradle . . USER gradle -RUN ./gradlew --no-daemon --stacktrace installDist +RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \ + ./gradlew --no-daemon --no-configuration-cache --stacktrace installDist FROM eclipse-temurin:21-jre-alpine RUN addgroup -S -g 10001 app \ @@ -21,6 +24,6 @@ EXPOSE 8080 STOPSIGNAL SIGTERM HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \ - CMD wget -q -O /dev/null http://127.0.0.1:8080/health/live || exit 1 + CMD wget -q -O /dev/null http://127.0.0.1:8080/health/ready || exit 1 ENTRYPOINT ["/app/bin/OSGAccountServer"] diff --git a/compose.smoke.yaml b/compose.smoke.yaml new file mode 100644 index 0000000..f989191 --- /dev/null +++ b/compose.smoke.yaml @@ -0,0 +1,121 @@ +services: + mysql: + image: mysql:8.4 + environment: + MYSQL_DATABASE: osg_account_smoke + MYSQL_ROOT_PASSWORD: ${SMOKE_MYSQL_ROOT_PASSWORD:?generated by deploy/smoke-local.sh} + TZ: UTC + volumes: + - smoke-mysql-data:/var/lib/mysql + healthcheck: + test: + [ + "CMD-SHELL", + "MYSQL_PWD=$$MYSQL_ROOT_PASSWORD mysqladmin ping --host=127.0.0.1 --user=root --silent", + ] + interval: 2s + timeout: 3s + retries: 60 + networks: + - smoke-internal + + schema-migrator: + profiles: ["setup"] + image: osg-account-server-smoke:${SMOKE_RUN_ID:?generated by deploy/smoke-local.sh} + build: + context: . + dockerfile: Dockerfile + init: true + user: "10001:10001" + env_file: + - ${SMOKE_RUNTIME_ENV:?generated by deploy/smoke-local.sh} + environment: + ADMIN_ENABLED: "false" + ADMIN_BOOTSTRAP_ENABLED: "false" + DATABASE_USER: osg_smoke_migrator + DATABASE_PASSWORD: ${SMOKE_MYSQL_MIGRATION_PASSWORD:?generated by deploy/smoke-local.sh} + depends_on: + mysql: + condition: service_healthy + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777,noexec,nosuid,nodev + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + networks: + - smoke-internal + + credential-generator: + profiles: ["setup"] + image: osg-account-server-smoke:${SMOKE_RUN_ID:?generated by deploy/smoke-local.sh} + build: + context: . + dockerfile: Dockerfile + entrypoint: + - java + - -cp + - /app/lib/* + - com.osglab.account.tools.AdminCredentialGenerator + command: + - smoke-admin + - /run/smoke/admin.generated.env + - /run/smoke/admin-handoff.txt + user: "${SMOKE_HOST_UID:?generated by deploy/smoke-local.sh}:${SMOKE_HOST_GID:?generated by deploy/smoke-local.sh}" + volumes: + - ${SMOKE_SECRET_DIR:?generated by deploy/smoke-local.sh}:/run/smoke + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + network_mode: none + + account-server: + image: osg-account-server-smoke:${SMOKE_RUN_ID:?generated by deploy/smoke-local.sh} + build: + context: . + dockerfile: Dockerfile + init: true + user: "10001:10001" + env_file: + - ${SMOKE_RUNTIME_ENV:?generated by deploy/smoke-local.sh} + - ${SMOKE_ADMIN_ENV:?generated by deploy/smoke-local.sh} + depends_on: + mysql: + condition: service_healthy + ports: + - "127.0.0.1:${SMOKE_APP_PORT:?generated by deploy/smoke-local.sh}:8080" + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777,noexec,nosuid,nodev + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + healthcheck: + test: + [ + "CMD", + "wget", + "-q", + "-O", + "/dev/null", + "http://127.0.0.1:8080/health/ready", + ] + interval: 2s + timeout: 3s + retries: 60 + networks: + - smoke-internal + - smoke-loopback + +volumes: + smoke-mysql-data: + +networks: + smoke-internal: + internal: true + smoke-loopback: + driver: bridge diff --git a/compose.yaml b/compose.yaml index 5b1c9a3..16e1d3b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -31,6 +31,15 @@ services: IDENTITY_HMAC_KEY: ${IDENTITY_HMAC_KEY:?set a distinct Base64 key} IDENTITY_TOMBSTONE_RETENTION_DAYS: ${IDENTITY_TOMBSTONE_RETENTION_DAYS:-365} + ADMIN_ENABLED: ${ADMIN_ENABLED:-false} + ADMIN_BOOTSTRAP_ENABLED: ${ADMIN_BOOTSTRAP_ENABLED:-false} + ADMIN_BOOTSTRAP_OPERATOR_ID: ${ADMIN_BOOTSTRAP_OPERATOR_ID:-} + ADMIN_BOOTSTRAP_USERNAME: ${ADMIN_BOOTSTRAP_USERNAME:-} + ADMIN_BOOTSTRAP_PASSWORD_HASH: ${ADMIN_BOOTSTRAP_PASSWORD_HASH:-} + ADMIN_BOOTSTRAP_TOTP_SECRET_BASE32: ${ADMIN_BOOTSTRAP_TOTP_SECRET_BASE32:-} + ADMIN_SESSION_HOURS: ${ADMIN_SESSION_HOURS:-8} + ADMIN_MAXIMUM_MANUAL_GRANT: ${ADMIN_MAXIMUM_MANUAL_GRANT:-100000} + APPLE_TEAM_ID: ${APPLE_TEAM_ID:?set Apple team ID} APPLE_KEY_ID: ${APPLE_KEY_ID:?set Apple key ID} APPLE_CLIENT_ID: ${APPLE_CLIENT_ID:-com.osgkeyboard.ios} diff --git a/deploy/mtls/README.md b/deploy/mtls/README.md new file mode 100644 index 0000000..45af2e4 --- /dev/null +++ b/deploy/mtls/README.md @@ -0,0 +1,86 @@ +# 管理端 mTLS 部署 + +`account.osglab.com` 在同一个 TLS `server` 中同时承载移动端 API 和管理端。 +由于 TLS 握手发生在 HTTP 路径匹配之前,配置必须使用 server 级 +`ssl_verify_client optional`:普通客户端不提供证书时仍可正常访问,只有 +`/admin`、`/admin/`、`/v1/admin` 和其子路径要求验证成功。 + +## CA 与证书 + +1. 为管理客户端创建独立私有 CA,不要复用公网服务端证书 CA 或其他内部 CA。 +2. CA 私钥离线保存;不要放入仓库、OpenResty 主机或容器镜像。 +3. 管理客户端证书使用短有效期和唯一密钥,并限制为 TLS Client Authentication + (`clientAuth`) 用途。 +4. 仅将 CA 证书链(不含任何私钥)部署到: + `/www/server/openresty/conf/mtls/admin-client-ca.pem` +5. CA 文件由 OpenResty 运行用户只读,目录不可由应用进程或非特权用户写入。 +6. 更新 CA 文件后先运行 `openresty -t`,成功后再平滑重载。若需要立即吊销证书, + 应另外配置并维护 `ssl_crl`;当前配置只依据证书链和有效期验证。 + +不要把客户端证书、客户端私钥、CA 私钥或生产证书标识提交到仓库。 +计划轮换与紧急处置步骤见 [ROTATION.md](ROTATION.md)。 + +## 上游信任边界 + +OpenResty 仅在管理路径且 `$ssl_client_verify = SUCCESS` 时向 Ktor 设置固定头: + +```text +X-OSG-mTLS-Verified: SUCCESS +``` + +客户端传入的同名头会被覆盖;其他路径会删除该头。Ktor 只能把这个头作为“边缘已验证” +信号,不能信任客户端提供的证书相关头,也不能用 DN、CN 或证书正文做隐式授权。 +后端端口必须继续只监听 `127.0.0.1:18080`,否则攻击者可绕过边缘伪造该头。 +mTLS 只证明客户端持有受信证书,管理接口仍应执行应用层身份认证、授权和审计。 + +## 一次性管理员 Bootstrap + +首次部署前运行 `./gradlew generateAdminCredentials`,将生成的 runtime 文件仅临时写入 +1Panel/Compose 环境,并同时设置: + +```text +ADMIN_ENABLED=true +ADMIN_BOOTSTRAP_ENABLED=true +``` + +确认初始管理员已创建且可以登录后,必须将 `ADMIN_BOOTSTRAP_ENABLED` 改回 `false`, +并从 1Panel、Compose 环境和部署文件中永久删除 +`ADMIN_BOOTSTRAP_OPERATOR_ID`、`ADMIN_BOOTSTRAP_USERNAME`、 +`ADMIN_BOOTSTRAP_PASSWORD_HASH`、`ADMIN_BOOTSTRAP_TOTP_SECRET_BASE32`。 +日常运行只保留 `ADMIN_ENABLED=true`。重启后再次验证登录,确保服务不再依赖 Bootstrap +秘密。 + +## 验证 + +将测试域名解析到目标边缘后执行: + +```sh +# 无证书:管理路径必须是 404。 +curl -i https://account.osglab.com/admin +curl -i https://account.osglab.com/v1/admin + +# 有效管理证书:请求应到达 Ktor,状态码由管理接口决定。 +curl -i --cert admin-client.pem --key admin-client-key.pem \ + https://account.osglab.com/v1/admin + +# 无证书的普通移动端 API:响应应与变更前一致。 +curl -i https://account.osglab.com/health + +# 即使客户端伪造信任头,无证书访问管理路径仍必须是 404。 +curl -i -H 'X-OSG-mTLS-Verified: SUCCESS' \ + https://account.osglab.com/v1/admin +``` + +还应使用由非管理 CA 签发或已过期的客户端证书确认返回 404,并在 Ktor 测试端点确认: +管理请求只收到固定值 `SUCCESS`,普通 API 不收到 `X-OSG-mTLS-Verified`。 + +部署后可在受信设备运行不含登录凭据的自动验收: + +```sh +ADMIN_CLIENT_CERT=/secure/path/admin-client.pem \ +ADMIN_CLIENT_KEY=/secure/path/admin-client-key.pem \ +bash deploy/verify-admin.sh +``` + +脚本验证公开健康检查、无证书隐藏、伪造边缘头拦截、有效证书访问,以及 HSTS/CSP +安全响应头;它不会读取或传输管理员密码和 TOTP 密钥。 diff --git a/deploy/mtls/ROTATION.md b/deploy/mtls/ROTATION.md new file mode 100644 index 0000000..2eda0a3 --- /dev/null +++ b/deploy/mtls/ROTATION.md @@ -0,0 +1,44 @@ +# 管理端 mTLS 证书轮换 + +管理端客户端证书应至少每 180 天轮换一次;设备丢失、人员离职或私钥疑似泄露时立即轮换。 +当前配置未启用 CRL,因此单个客户端证书无法被可靠撤销。出现紧急事件时必须轮换整个管理端 +CA,不能只删除本地 `.p12` 文件。 + +## 零中断轮换 + +1. 在离线受信设备上生成一套新的 CA 和客户端证书,输出到带日期的新目录。不要覆盖现有目录。 +2. 校验新证书的有效期、用途和私钥匹配关系: + + ```shell + openssl verify -CAfile admin-client-ca.pem admin-client.pem + openssl x509 -in admin-client.pem -noout -dates -subject -issuer + openssl pkcs12 -in admin-client.p12 -info -noout + ``` + +3. 将旧 CA 与新 CA 的公开证书按顺序合并为临时信任包。信任包不得包含任何 CA 私钥: + + ```shell + cp current-admin-client-ca.pem admin-client-ca-transition.pem + printf '\n' >> admin-client-ca-transition.pem + openssl x509 -in new-admin-client-ca.pem -outform PEM >> admin-client-ca-transition.pem + ``` + +4. 通过 1Panel 将临时信任包替换到 OpenResty 配置引用的 + `/www/server/openresty/conf/mtls/admin-client-ca.pem`,先执行 `openresty -t`,成功后再平滑重载。 +5. 分别使用旧客户端证书和新客户端证书验证 `/admin/` 与 `/v1/admin/auth/session`。 +6. 通过受控的点对点渠道交付新的 `.p12` 与独立密码。不得使用工单附件、群聊、Git 或公开网盘。 +7. 确认所有获准设备完成安装后,将服务器信任包替换为仅包含新 CA 的证书,再次执行 + `nginx -t` 和平滑重载。 +8. 验证新证书成功、旧证书返回 404,然后安全销毁旧客户端私钥和旧 CA 私钥。 + +## 验收记录 + +每次轮换必须在安全审计记录中保存以下非敏感信息: + +- 轮换时间、执行人与复核人 +- 新 CA 和客户端证书的 SHA-256 指纹与失效时间 +- 新旧证书切换验证结果 +- 旧证书停止信任的准确时间 +- 触发原因:计划轮换、设备丢失、人员变更或疑似泄露 + +不得记录 `.p12` 密码、TOTP 密钥、私钥内容或管理员密码。 diff --git a/deploy/mtls/generate_admin_certificates.py b/deploy/mtls/generate_admin_certificates.py new file mode 100644 index 0000000..d679cbb --- /dev/null +++ b/deploy/mtls/generate_admin_certificates.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Generate a dedicated admin client CA and one short-lived client certificate.""" + +from __future__ import annotations + +import argparse +import base64 +import datetime as dt +import os +from pathlib import Path + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.serialization import pkcs12 +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + + +def private_write(path: Path, value: bytes) -> None: + path.write_bytes(value) + path.chmod(0o600) + + +def public_write(path: Path, value: bytes) -> None: + path.write_bytes(value) + path.chmod(0o644) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("output_directory", type=Path) + parser.add_argument("--client-name", default="osg-admin-owner") + args = parser.parse_args() + + output = args.output_directory.expanduser().resolve() + if output.exists(): + raise SystemExit("output_directory_already_exists") + output.mkdir(mode=0o700, parents=False) + + now = dt.datetime.now(dt.timezone.utc) + ca_key = ec.generate_private_key(ec.SECP384R1()) + ca_subject = x509.Name( + [ + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "OSG Admin"), + x509.NameAttribute(NameOID.COMMON_NAME, "OSG Admin Client CA"), + ] + ) + ca_certificate = ( + x509.CertificateBuilder() + .subject_name(ca_subject) + .issuer_name(ca_subject) + .public_key(ca_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - dt.timedelta(minutes=5)) + .not_valid_after(now + dt.timedelta(days=3650)) + .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) + .add_extension( + x509.KeyUsage( + digital_signature=False, + content_commitment=False, + key_encipherment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=True, + crl_sign=True, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension(x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), False) + .sign(ca_key, hashes.SHA384()) + ) + + client_key = ec.generate_private_key(ec.SECP256R1()) + client_subject = x509.Name( + [ + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "OSG Admin"), + x509.NameAttribute(NameOID.COMMON_NAME, args.client_name), + ] + ) + client_certificate = ( + x509.CertificateBuilder() + .subject_name(client_subject) + .issuer_name(ca_certificate.subject) + .public_key(client_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - dt.timedelta(minutes=5)) + .not_valid_after(now + dt.timedelta(days=180)) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .add_extension( + x509.KeyUsage( + digital_signature=True, + content_commitment=False, + key_encipherment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=False, + crl_sign=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension( + x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), + critical=True, + ) + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(client_key.public_key()), + critical=False, + ) + .add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), + critical=False, + ) + .sign(ca_key, hashes.SHA256()) + ) + + password = base64.urlsafe_b64encode(os.urandom(24)).rstrip(b"=") + private_write( + output / "admin-client-ca-key.pem", + ca_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ), + ) + public_write( + output / "admin-client-ca.pem", + ca_certificate.public_bytes(serialization.Encoding.PEM), + ) + private_write( + output / "admin-client-key.pem", + client_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ), + ) + public_write( + output / "admin-client.pem", + client_certificate.public_bytes(serialization.Encoding.PEM), + ) + private_write( + output / "admin-client.p12", + pkcs12.serialize_key_and_certificates( + args.client_name.encode(), + client_key, + client_certificate, + [ca_certificate], + serialization.BestAvailableEncryption(password), + ), + ) + private_write(output / "admin-client-p12-password.txt", password + b"\n") + print("certificates_created=true") + + +if __name__ == "__main__": + main() diff --git a/deploy/openresty-account.conf b/deploy/openresty-account.conf index 36ec9c8..5b19c83 100644 --- a/deploy/openresty-account.conf +++ b/deploy/openresty-account.conf @@ -31,6 +31,14 @@ server { ssl_protocols TLSv1.2 TLSv1.3; ssl_session_timeout 1d; ssl_session_cache shared:account_tls:10m; + # Request client certificates at the shared TLS edge without requiring them + # for mobile APIs. Only the dedicated admin CA is trusted for verification. + ssl_client_certificate /www/server/openresty/conf/mtls/admin-client-ca.pem; + ssl_verify_client optional; + ssl_verify_depth 2; + # An invalid certificate is rejected before location processing; normalize + # that TLS verification failure so it does not reveal the protected surface. + error_page 495 =404 @client_certificate_not_found; client_max_body_size 21m; server_tokens off; @@ -39,8 +47,48 @@ server { add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "no-referrer" always; - # Never publish operational or administrative paths through this vhost. - location ~ ^/(?:admin|internal|v1/admin)(?:/|$) { + # Internal-only endpoints remain unavailable through this public vhost. + location ~ ^/internal(?:/|$) { + return 404; + } + + # Administrative endpoints are indistinguishable from missing routes unless + # OpenResty verified a certificate issued by the dedicated admin client CA. + location ~ ^/(?:admin|v1/admin)(?:/|$) { + if ($ssl_client_verify != SUCCESS) { + return 404; + } + + client_max_body_size 32k; + limit_req zone=account_api burst=40 nodelay; + # Defining a location-level header disables inheritance from the server + # block, so repeat the security headers explicitly. + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "no-referrer" always; + add_header Content-Security-Policy "default-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'; object-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'" always; + add_header Cache-Control "no-store" always; + proxy_pass http://osg_account_server; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Request-ID $request_id; + # Overwrite any client-supplied value; Ktor must trust only this header. + proxy_set_header X-OSG-mTLS-Verified "SUCCESS"; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_hide_header Server; + proxy_request_buffering off; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 360s; + proxy_send_timeout 360s; + } + + location @client_certificate_not_found { return 404; } @@ -53,6 +101,8 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Request-ID $request_id; + # Suppress spoofed trust signals on every non-admin request. + proxy_set_header X-OSG-mTLS-Verified ""; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_hide_header Server; diff --git a/deploy/smoke-local.sh b/deploy/smoke-local.sh new file mode 100755 index 0000000..c9d0641 --- /dev/null +++ b/deploy/smoke-local.sh @@ -0,0 +1,564 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +COMPOSE_FILE="$ROOT_DIR/compose.smoke.yaml" +SMOKE_DIR="$ROOT_DIR/deploy/smoke" +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/osg-account-smoke.XXXXXX")" +SECRET_DIR="$WORK_DIR/secrets" +DIAGNOSTICS_FILE="$WORK_DIR/diagnostics.log" +COMPOSE_ENV="$SECRET_DIR/compose.env" +RUNTIME_ENV="$SECRET_DIR/runtime.env" +ADMIN_ENV="$SECRET_DIR/admin.env" +ADMIN_GENERATED_ENV="$SECRET_DIR/admin.generated.env" +ADMIN_HANDOFF="$SECRET_DIR/admin-handoff.txt" +SUPER_COOKIE_JAR="$SECRET_DIR/super.cookies" +ANALYST_COOKIE_JAR="$SECRET_DIR/analyst.cookies" +RESPONSE_BODY="$WORK_DIR/response.json" +ACTUAL_PRIVILEGES="$WORK_DIR/runtime-privileges.tsv" +RUN_ID="$(date -u +%Y%m%d%H%M%S)-$$" +PROJECT_NAME="osg-account-smoke-$RUN_ID" +SMOKE_ACCOUNT_ID="10000000-0000-0000-0000-000000000001" +FAILURES=0 + +mkdir -m 700 "$SECRET_DIR" +touch "$ADMIN_ENV" +chmod 600 "$ADMIN_ENV" + +log() { + printf '[smoke] %s\n' "$*" +} + +fail() { + printf '[smoke] FAIL: %s\n' "$*" >&2 + return 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || { + printf '[smoke] missing required command: %s\n' "$1" >&2 + exit 1 + } +} + +random_hex() { + openssl rand -hex "$1" +} + +random_base64_key() { + openssl rand -base64 32 | tr -d '\n' +} + +free_loopback_port() { + python3 - <<'PY' +import socket + +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +} + +compose() { + docker compose \ + --project-name "$PROJECT_NAME" \ + --env-file "$COMPOSE_ENV" \ + --file "$COMPOSE_FILE" \ + "$@" +} + +mysql_root() { + compose exec -T \ + -e "MYSQL_PWD=$MYSQL_ROOT_PASSWORD" \ + mysql mysql --protocol=tcp --host=127.0.0.1 --user=root "$@" +} + +mysql_runtime() { + compose exec -T \ + -e "MYSQL_PWD=$MYSQL_RUNTIME_PASSWORD" \ + mysql mysql --protocol=tcp --host=127.0.0.1 --user=osg_smoke_runtime "$@" +} + +capture_diagnostics() { + { + printf '%s\n' '=== compose ps ===' + compose --profile setup ps --all || true + printf '%s\n' '=== mysql/app logs (last 200 lines) ===' + compose --profile setup logs --no-color --tail 200 \ + mysql schema-migrator account-server || true + } >"$DIAGNOSTICS_FILE" 2>&1 + chmod 600 "$DIAGNOSTICS_FILE" +} + +cleanup() { + local status=$? + trap - EXIT INT TERM + if ((status != 0)); then + capture_diagnostics + fi + compose --profile setup down --volumes --remove-orphans --rmi local >/dev/null 2>&1 || true + rm -f \ + "$COMPOSE_ENV" \ + "$RUNTIME_ENV" \ + "$ADMIN_ENV" \ + "$ADMIN_GENERATED_ENV" \ + "$ADMIN_HANDOFF" \ + "$SUPER_COOKIE_JAR" \ + "$ANALYST_COOKIE_JAR" \ + "$RESPONSE_BODY" \ + "$ACTUAL_PRIVILEGES" \ + "$WORK_DIR/denied.err" + rmdir "$SECRET_DIR" 2>/dev/null || true + if ((status == 0)); then + rm -f "$DIAGNOSTICS_FILE" + rmdir "$WORK_DIR" 2>/dev/null || true + log "PASS: containers, volume, image, and temporary credentials removed" + else + printf '[smoke] non-sensitive diagnostics kept at %s\n' "$DIAGNOSTICS_FILE" >&2 + fi + exit "$status" +} +trap cleanup EXIT INT TERM + +wait_for_service_http() { + local service=$1 + local url=$2 + local attempts=${3:-90} + local attempt + for ((attempt = 1; attempt <= attempts; attempt++)); do + if compose exec -T "$service" wget -q -O /dev/null "$url" 2>/dev/null; then + return 0 + fi + if [[ "$(compose ps --all --format json "$service" 2>/dev/null || true)" == *'"State":"exited"'* ]]; then + return 1 + fi + sleep 2 + done + return 1 +} + +wait_for_host_http() { + local path=$1 + local attempts=${2:-30} + local attempt status + for ((attempt = 1; attempt <= attempts; attempt++)); do + status="$(curl --silent --output /dev/null --write-out '%{http_code}' \ + "http://127.0.0.1:$APP_PORT$path" 2>/dev/null || true)" + if [[ "$status" == "200" ]]; then + return 0 + fi + sleep 1 + done + return 1 +} + +http_request() { + local expected=$1 + local method=$2 + local path=$3 + local cookie_jar=$4 + local body=$5 + shift 5 + local -a command=( + curl --silent --show-error + --output "$RESPONSE_BODY" + --write-out '%{http_code}' + --request "$method" + "http://127.0.0.1:$APP_PORT$path" + ) + local header + if [[ "$cookie_jar" != "-" ]]; then + command+=(--cookie "$cookie_jar" --cookie-jar "$cookie_jar") + fi + if [[ -n "$body" ]]; then + command+=(--header 'Content-Type: application/json' --data "$body") + fi + for header in "$@"; do + command+=(--header "$header") + done + + local status + status="$("${command[@]}")" + [[ "$status" == "$expected" ]] || fail "$method $path returned $status, expected $expected" +} + +json_value() { + local file=$1 + shift + python3 - "$file" "$@" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1], encoding="utf-8")) +for key in sys.argv[2:]: + if key.isdigit(): + value = value[int(key)] + else: + value = value[key] +if value is None: + print("") +elif isinstance(value, bool): + print(str(value).lower()) +else: + print(value) +PY +} + +totp_code() { + local secret=$1 + python3 - "$secret" <<'PY' +import base64 +import hashlib +import hmac +import struct +import sys +import time + +secret = base64.b32decode(sys.argv[1], casefold=True) +counter = int(time.time()) // 30 +digest = hmac.new(secret, struct.pack(">Q", counter), hashlib.sha1).digest() +offset = digest[-1] & 0x0F +number = struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF +print(f"{number % 1_000_000:06d}") +PY +} + +url_encode() { + python3 - "$1" <<'PY' +import sys +import urllib.parse + +print(urllib.parse.quote(sys.argv[1], safe="")) +PY +} + +assert_json_value() { + local expected=$1 + shift + local actual + actual="$(json_value "$RESPONSE_BODY" "$@")" + [[ "$actual" == "$expected" ]] || fail "JSON value '$actual' did not equal '$expected'" +} + +verify_privilege_matrix() { + mysql_root --batch --skip-column-names information_schema >"$ACTUAL_PRIVILEGES" <<'SQL' +SELECT TABLE_NAME, PRIVILEGE_TYPE +FROM TABLE_PRIVILEGES +WHERE GRANTEE = '''osg_smoke_runtime''@''%''' + AND TABLE_SCHEMA = 'osg_account_smoke' +ORDER BY TABLE_NAME, PRIVILEGE_TYPE; +SQL + + python3 - "$SMOKE_DIR/runtime-grants.sql" "$ACTUAL_PRIVILEGES" <<'PY' +import re +import sys + +grant_pattern = re.compile( + r"^GRANT\s+(.+?)\s+ON\s+osg_account_smoke\.([a-z0-9_]+)\s+TO\s+'osg_smoke_runtime'@'%';$", + re.IGNORECASE, +) +expected = set() +for raw_line in open(sys.argv[1], encoding="utf-8"): + match = grant_pattern.match(raw_line.strip()) + if match: + for privilege in match.group(1).split(","): + expected.add((match.group(2).lower(), privilege.strip().upper())) + +actual = set() +for raw_line in open(sys.argv[2], encoding="utf-8"): + table, privilege = raw_line.rstrip("\n").split("\t") + actual.add((table.lower(), privilege.upper())) + +missing = sorted(expected - actual) +unexpected = sorted(actual - expected) +if missing or unexpected: + print(f"missing privileges: {missing}", file=sys.stderr) + print(f"unexpected privileges: {unexpected}", file=sys.stderr) + raise SystemExit(1) +PY + + local elevated_count + elevated_count="$(mysql_root --batch --skip-column-names information_schema <<'SQL' +SELECT + (SELECT COUNT(*) FROM USER_PRIVILEGES + WHERE GRANTEE = '''osg_smoke_runtime''@''%''' AND PRIVILEGE_TYPE <> 'USAGE') + + (SELECT COUNT(*) FROM SCHEMA_PRIVILEGES + WHERE GRANTEE = '''osg_smoke_runtime''@''%''') + + (SELECT COUNT(*) FROM mysql.procs_priv + WHERE User = 'osg_smoke_runtime' AND Host = '%'); +SQL +)" + [[ "$elevated_count" == "0" ]] || fail "runtime user received global, schema, or routine privileges" +} + +expect_runtime_denied() { + local description=$1 + local statement=$2 + if mysql_runtime osg_account_smoke --execute "$statement" \ + >/dev/null 2>"$WORK_DIR/denied.err"; then + fail "$description unexpectedly succeeded" + fi +} + +verify_immutable_history_denials() { + expect_runtime_denied \ + "credit ledger UPDATE" \ + "UPDATE credit_ledger SET amount_delta = amount_delta WHERE 1 = 0" + expect_runtime_denied \ + "credit ledger DELETE" \ + "DELETE FROM credit_ledger WHERE 1 = 0" + expect_runtime_denied \ + "admin audit UPDATE" \ + "UPDATE admin_audit_log SET outcome = outcome WHERE 1 = 0" + expect_runtime_denied \ + "admin audit DELETE" \ + "DELETE FROM admin_audit_log WHERE 1 = 0" + expect_runtime_denied \ + "admin grant UPDATE" \ + "UPDATE admin_credit_grants SET amount = amount WHERE 1 = 0" + expect_runtime_denied \ + "admin grant DELETE" \ + "DELETE FROM admin_credit_grants WHERE 1 = 0" + expect_runtime_denied \ + "Flyway metadata read" \ + "SELECT version FROM flyway_schema_history LIMIT 1" +} + +verify_session_cleanup_permission() { + mysql_runtime osg_account_smoke \ + --execute "DELETE FROM admin_sessions WHERE expires_at < UTC_TIMESTAMP() AND 1 = 0" +} + +verify_ledger_pagination() { + local cursor first_id second_id encoded_cursor + http_request 200 GET "/v1/admin/users/$SMOKE_ACCOUNT_ID/ledger" "$SUPER_COOKIE_JAR" "" \ + 'X-OSG-mTLS-Verified: SUCCESS' + [[ "$(json_value "$RESPONSE_BODY" items | tr -d '\n')" != "" ]] || fail "ledger page was empty" + [[ "$(python3 - "$RESPONSE_BODY" <<'PY' +import json +import sys +print(len(json.load(open(sys.argv[1], encoding="utf-8"))["items"])) +PY +)" == "100" ]] || fail "first ledger page did not contain 100 entries" + first_id="$(json_value "$RESPONSE_BODY" items 0 entryId)" + cursor="$(json_value "$RESPONSE_BODY" nextCursor)" + [[ -n "$cursor" ]] || fail "first ledger page omitted nextCursor with more than 100 entries" + + encoded_cursor="$(url_encode "$cursor")" + http_request 200 GET \ + "/v1/admin/users/$SMOKE_ACCOUNT_ID/ledger?cursor=$encoded_cursor" \ + "$SUPER_COOKIE_JAR" "" \ + 'X-OSG-mTLS-Verified: SUCCESS' + second_id="$(json_value "$RESPONSE_BODY" items 0 entryId)" + [[ -n "$second_id" && "$second_id" != "$first_id" ]] || fail "ledger cursor repeated the first page" +} + +require_command curl +require_command docker +require_command openssl +require_command python3 + +APP_PORT="$(free_loopback_port)" +MYSQL_ROOT_PASSWORD="$(random_hex 24)" +MYSQL_RUNTIME_PASSWORD="$(random_hex 24)" +MYSQL_MIGRATION_PASSWORD="$(random_hex 24)" +JWT_SECRET="$(random_hex 32)" +FIELD_ENCRYPTION_KEY="$(random_base64_key)" +IDENTITY_HMAC_KEY="$(random_base64_key)" + +cat >"$COMPOSE_ENV" <"$RUNTIME_ENV" </dev/null +mv "$ADMIN_GENERATED_ENV" "$ADMIN_ENV" +chmod 600 "$ADMIN_ENV" "$ADMIN_HANDOFF" +ADMIN_PASSWORD="$(awk -F ':' '/^密码:/{print $2}' "$ADMIN_HANDOFF")" +ADMIN_TOTP_SECRET="$(awk -F ':' '/^TOTP 密钥:/{print $2}' "$ADMIN_HANDOFF")" +[[ -n "$ADMIN_PASSWORD" && -n "$ADMIN_TOTP_SECRET" ]] || fail "administrator credential generation failed" + +log "starting disposable MySQL 8.4" +compose up --detach --wait --wait-timeout 120 mysql + +log "creating isolated migration and runtime users" +mysql_root </dev/null + +log "installing exact runtime grants and disposable fixture" +mysql_root osg_account_smoke <"$SMOKE_DIR/runtime-grants.sql" +mysql_root osg_account_smoke <"$SMOKE_DIR/fixture.sql" +verify_privilege_matrix + +log "starting application with runtime-only database access" +compose up --detach account-server +wait_for_service_http account-server 'http://127.0.0.1:8080/health/ready' || + fail "account server did not become ready" +wait_for_host_http '/health/ready' || + fail "account server loopback port did not become ready" +http_request 200 GET '/health/ready' - '' +assert_json_value UP status + +log "verifying hidden admin edge and authenticated session" +http_request 404 GET '/v1/admin/auth/session' - '' +http_request 200 GET '/v1/admin/auth/session' - '' 'X-OSG-mTLS-Verified: SUCCESS' +assert_json_value false authenticated + +ADMIN_TOTP_CODE="$(totp_code "$ADMIN_TOTP_SECRET")" +http_request 200 POST '/v1/admin/auth/login' "$SUPER_COOKIE_JAR" \ + "{\"username\":\"smoke-admin\",\"password\":\"$ADMIN_PASSWORD\",\"totpCode\":\"$ADMIN_TOTP_CODE\"}" \ + 'X-OSG-mTLS-Verified: SUCCESS' \ + "Origin: http://127.0.0.1:$APP_PORT" +assert_json_value SUPER_ADMIN role +CSRF_TOKEN="$(json_value "$RESPONSE_BODY" csrfToken)" + +log "verifying CSRF, RBAC, core reads, and append-only grant path" +http_request 403 POST '/v1/admin/credits/grants' "$SUPER_COOKIE_JAR" \ + "{\"userId\":\"$SMOKE_ACCOUNT_ID\",\"amount\":7,\"reason\":\"smoke grant\"}" \ + 'X-OSG-mTLS-Verified: SUCCESS' \ + "Origin: http://127.0.0.1:$APP_PORT" \ + 'Idempotency-Key: smoke-grant-no-csrf' +assert_json_value CSRF_INVALID code + +http_request 200 POST '/v1/admin/credits/grants' "$SUPER_COOKIE_JAR" \ + "{\"userId\":\"$SMOKE_ACCOUNT_ID\",\"amount\":7,\"reason\":\"smoke grant\"}" \ + 'X-OSG-mTLS-Verified: SUCCESS' \ + "Origin: http://127.0.0.1:$APP_PORT" \ + "X-CSRF-Token: $CSRF_TOKEN" \ + 'Idempotency-Key: smoke-grant-success' +assert_json_value 108 balanceAfter + +for path in \ + '/v1/admin/overview?range=7d' \ + '/v1/admin/referrals?range=7d' \ + '/v1/admin/users' \ + "/v1/admin/users/$SMOKE_ACCOUNT_ID" \ + '/v1/admin/operators/summary' \ + '/v1/admin/operators' \ + '/v1/admin/audit'; do + http_request 200 GET "$path" "$SUPER_COOKIE_JAR" '' 'X-OSG-mTLS-Verified: SUCCESS' +done + +http_request 201 POST '/v1/admin/operators' "$SUPER_COOKIE_JAR" \ + '{"username":"smoke-analyst","password":"smoke-analyst-password","role":"ANALYST"}' \ + 'X-OSG-mTLS-Verified: SUCCESS' \ + "Origin: http://127.0.0.1:$APP_PORT" \ + "X-CSRF-Token: $CSRF_TOKEN" +ANALYST_TOTP_SECRET="$(json_value "$RESPONSE_BODY" totpSecret)" +ANALYST_TOTP_CODE="$(totp_code "$ANALYST_TOTP_SECRET")" +http_request 200 POST '/v1/admin/auth/login' "$ANALYST_COOKIE_JAR" \ + "{\"username\":\"smoke-analyst\",\"password\":\"smoke-analyst-password\",\"totpCode\":\"$ANALYST_TOTP_CODE\"}" \ + 'X-OSG-mTLS-Verified: SUCCESS' \ + "Origin: http://127.0.0.1:$APP_PORT" +http_request 403 GET '/v1/admin/users' "$ANALYST_COOKIE_JAR" '' \ + 'X-OSG-mTLS-Verified: SUCCESS' +assert_json_value INSUFFICIENT_PERMISSION code + +if ! verify_ledger_pagination; then + FAILURES=$((FAILURES + 1)) + printf '[smoke] ledger pagination verification failed\n' >&2 +fi + +verify_immutable_history_denials +verify_session_cleanup_permission + +log "verifying logout and session revocation" +http_request 204 POST '/v1/admin/auth/logout' "$SUPER_COOKIE_JAR" '' \ + 'X-OSG-mTLS-Verified: SUCCESS' \ + "Origin: http://127.0.0.1:$APP_PORT" \ + "X-CSRF-Token: $CSRF_TOKEN" +ACTIVE_SUPER_SESSIONS="$(mysql_runtime --batch --skip-column-names osg_account_smoke <<'SQL' +SELECT COUNT(*) +FROM admin_sessions AS session +JOIN admin_operators AS operator ON operator.id = session.operator_id +WHERE operator.username = 'smoke-admin' + AND session.revoked_at IS NULL; +SQL +)" +[[ "$ACTIVE_SUPER_SESSIONS" == "0" ]] || fail "logout did not revoke the super-administrator session" + +((FAILURES == 0)) || fail "$FAILURES smoke verification(s) failed" diff --git a/deploy/smoke/README.md b/deploy/smoke/README.md new file mode 100644 index 0000000..551307a --- /dev/null +++ b/deploy/smoke/README.md @@ -0,0 +1,28 @@ +# Local Docker smoke verification + +Run from any directory: + +```bash +/Users/rocky/Documents/OSGAccountServer/deploy/smoke-local.sh +``` + +Requirements: Docker with Compose v2, `curl`, `openssl`, and Python 3. + +The runner builds the current checkout, creates an isolated MySQL 8.4 project, +generates one-time keys and administrator credentials, and uses an internal +Docker network. Apple, DeepSeek, and Volcengine endpoints point to the +container's closed loopback port, so no provider request can leave the project. + +The checks cover: + +- successful Flyway V1-V8 history and `/health/ready`; +- hidden admin routes without the trusted edge header; +- TOTP login, cookies, CSRF, role denial, core reads, grants, and logout; +- a 101-row ledger fixture that requires a second cursor page; +- an exact per-table runtime grant matrix; +- denied `UPDATE` and `DELETE` on ledger, audit, and grant history. + +Containers, the named volume, the local smoke image, cookie jars, generated +keys, and administrator credentials are removed on exit. On failure, only +recent container status and logs remain in the printed temporary diagnostics +path. diff --git a/deploy/smoke/fixture.sql b/deploy/smoke/fixture.sql new file mode 100644 index 0000000..9c49fdc --- /dev/null +++ b/deploy/smoke/fixture.sql @@ -0,0 +1,47 @@ +-- A disposable account with more than one admin-ledger page. +INSERT INTO accounts ( + id, + apple_sub, + created_at, + updated_at, + identity_fingerprint, + anti_abuse_restricted +) VALUES ( + '10000000-0000-0000-0000-000000000001', + 'smoke-only-apple-subject', + UTC_TIMESTAMP(6), + UTC_TIMESTAMP(6), + REPEAT('1', 64), + FALSE +); + +INSERT INTO credit_accounts (user_id, balance, updated_at) +VALUES ('10000000-0000-0000-0000-000000000001', 101, UTC_TIMESTAMP(6)); + +INSERT INTO credit_ledger ( + id, + user_id, + entry_type, + amount_delta, + balance_after, + idempotency_key, + reference_id, + created_at +) +WITH RECURSIVE sequence_number AS ( + SELECT 1 AS value + UNION ALL + SELECT value + 1 + FROM sequence_number + WHERE value < 101 +) +SELECT + CONCAT('20000000-0000-0000-0000-', LPAD(value, 12, '0')), + '10000000-0000-0000-0000-000000000001', + 'SIGNUP_TRIAL', + 1, + value, + CONCAT('smoke-ledger-', LPAD(value, 4, '0')), + NULL, + TIMESTAMPADD(MICROSECOND, value, '2026-01-01 00:00:00.000000') +FROM sequence_number; diff --git a/deploy/smoke/runtime-grants.sql b/deploy/smoke/runtime-grants.sql new file mode 100644 index 0000000..cbbad20 --- /dev/null +++ b/deploy/smoke/runtime-grants.sql @@ -0,0 +1,54 @@ +-- Smoke-only grants. The user and schema are disposable and never point at production. +GRANT SELECT ON osg_account_smoke.accounts TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.apple_credentials TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.sessions TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.apple_event_receipts TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.credit_accounts TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.credit_rate_versions TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.credit_reservations TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.referral_campaigns TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.referral_campaign_budgets TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.referral_codes TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.referral_bindings TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.credit_usage_records TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.credit_ledger TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.provider_requests TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.usage_records TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.gateway_grants TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.gateway_grant_scopes TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.gateway_refresh_tokens TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.devicecheck_trial_claims TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.app_attest_challenges TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.app_attest_keys TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.account_identity_tombstones TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.apple_revocation_outbox TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.admin_operators TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.admin_sessions TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.admin_audit_log TO 'osg_smoke_runtime'@'%'; +GRANT SELECT ON osg_account_smoke.admin_credit_grants TO 'osg_smoke_runtime'@'%'; + +GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.accounts TO 'osg_smoke_runtime'@'%'; +GRANT INSERT, UPDATE ON osg_account_smoke.apple_credentials TO 'osg_smoke_runtime'@'%'; +GRANT INSERT, UPDATE ON osg_account_smoke.sessions TO 'osg_smoke_runtime'@'%'; +GRANT INSERT ON osg_account_smoke.apple_event_receipts TO 'osg_smoke_runtime'@'%'; +GRANT INSERT, UPDATE ON osg_account_smoke.credit_accounts TO 'osg_smoke_runtime'@'%'; +GRANT INSERT, UPDATE ON osg_account_smoke.credit_reservations TO 'osg_smoke_runtime'@'%'; +GRANT UPDATE ON osg_account_smoke.referral_campaign_budgets TO 'osg_smoke_runtime'@'%'; +GRANT INSERT ON osg_account_smoke.referral_codes TO 'osg_smoke_runtime'@'%'; +GRANT INSERT, UPDATE ON osg_account_smoke.referral_bindings TO 'osg_smoke_runtime'@'%'; +GRANT INSERT ON osg_account_smoke.credit_usage_records TO 'osg_smoke_runtime'@'%'; +GRANT INSERT ON osg_account_smoke.credit_ledger TO 'osg_smoke_runtime'@'%'; +GRANT INSERT, UPDATE ON osg_account_smoke.provider_requests TO 'osg_smoke_runtime'@'%'; +GRANT INSERT ON osg_account_smoke.usage_records TO 'osg_smoke_runtime'@'%'; +GRANT INSERT, UPDATE ON osg_account_smoke.gateway_grants TO 'osg_smoke_runtime'@'%'; +GRANT INSERT ON osg_account_smoke.gateway_grant_scopes TO 'osg_smoke_runtime'@'%'; +GRANT INSERT, UPDATE ON osg_account_smoke.gateway_refresh_tokens TO 'osg_smoke_runtime'@'%'; +GRANT INSERT, UPDATE ON osg_account_smoke.devicecheck_trial_claims TO 'osg_smoke_runtime'@'%'; +GRANT INSERT, UPDATE ON osg_account_smoke.app_attest_challenges TO 'osg_smoke_runtime'@'%'; +GRANT INSERT, UPDATE ON osg_account_smoke.app_attest_keys TO 'osg_smoke_runtime'@'%'; +GRANT INSERT, UPDATE ON osg_account_smoke.account_identity_tombstones TO 'osg_smoke_runtime'@'%'; +GRANT INSERT, UPDATE ON osg_account_smoke.apple_revocation_outbox TO 'osg_smoke_runtime'@'%'; +GRANT INSERT, UPDATE ON osg_account_smoke.admin_operators TO 'osg_smoke_runtime'@'%'; +GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.admin_sessions TO 'osg_smoke_runtime'@'%'; +GRANT INSERT ON osg_account_smoke.admin_audit_log TO 'osg_smoke_runtime'@'%'; +GRANT INSERT ON osg_account_smoke.admin_credit_grants TO 'osg_smoke_runtime'@'%'; diff --git a/deploy/verify-admin.sh b/deploy/verify-admin.sh new file mode 100644 index 0000000..095ce2e --- /dev/null +++ b/deploy/verify-admin.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${BASE_URL:-https://account.osglab.com}" +ADMIN_CLIENT_CERT="${ADMIN_CLIENT_CERT:-}" +ADMIN_CLIENT_KEY="${ADMIN_CLIENT_KEY:-}" + +if [[ -z "$ADMIN_CLIENT_CERT" || -z "$ADMIN_CLIENT_KEY" ]]; then + echo "Set ADMIN_CLIENT_CERT and ADMIN_CLIENT_KEY to local PEM paths." >&2 + exit 2 +fi + +work_directory="$(mktemp -d)" +trap 'rm -rf "$work_directory"' EXIT + +request_status() { + local name="$1" + local expected="$2" + shift 2 + + local status + status="$(curl --silent --show-error \ + --output "$work_directory/$name.body" \ + --dump-header "$work_directory/$name.headers" \ + --write-out '%{http_code}' \ + "$@")" + + if [[ "$status" != "$expected" ]]; then + echo "$name expected HTTP $expected but received $status" >&2 + exit 1 + fi +} + +request_status "health" "200" "$BASE_URL/health" +request_status "admin-without-certificate" "404" "$BASE_URL/admin/" +request_status "forged-edge-header" "404" \ + --header "X-OSG-mTLS-Verified: SUCCESS" \ + "$BASE_URL/v1/admin/auth/session" +request_status "admin-with-certificate" "200" \ + --cert "$ADMIN_CLIENT_CERT" \ + --key "$ADMIN_CLIENT_KEY" \ + "$BASE_URL/admin/" +request_status "session-with-certificate" "200" \ + --cert "$ADMIN_CLIENT_CERT" \ + --key "$ADMIN_CLIENT_KEY" \ + "$BASE_URL/v1/admin/auth/session" + +if ! grep -Fq "OSG 运营后台" "$work_directory/admin-with-certificate.body"; then + echo "Admin HTML marker was not found." >&2 + exit 1 +fi + +if ! grep -Eiq '^strict-transport-security: .*max-age=31536000' \ + "$work_directory/admin-with-certificate.headers"; then + echo "Strict-Transport-Security header is missing." >&2 + exit 1 +fi + +if ! grep -Eiq "^content-security-policy: .*script-src 'self'; style-src 'self'" \ + "$work_directory/admin-with-certificate.headers"; then + echo "The expected strict Content-Security-Policy header is missing." >&2 + exit 1 +fi + +if grep -Fiq "unsafe-inline" "$work_directory/admin-with-certificate.headers"; then + echo "Content-Security-Policy unexpectedly permits unsafe-inline." >&2 + exit 1 +fi + +echo "Admin edge acceptance checks passed." diff --git a/docs/mysql-minimum-privileges.sql b/docs/mysql-minimum-privileges.sql index e4459be..ebc48c5 100644 --- a/docs/mysql-minimum-privileges.sql +++ b/docs/mysql-minimum-privileges.sql @@ -27,11 +27,17 @@ GRANT SELECT ON osg_account.credit_ledger TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.provider_requests TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.usage_records TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.gateway_grants TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.gateway_grant_scopes TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.gateway_refresh_tokens TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.devicecheck_trial_claims TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.app_attest_challenges TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.app_attest_keys TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.account_identity_tombstones TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.apple_revocation_outbox TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.admin_operators TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.admin_sessions TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.admin_audit_log TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.admin_credit_grants TO 'osg_account_runtime'@'10.20.%'; GRANT INSERT, UPDATE, DELETE ON osg_account.accounts TO 'osg_account_runtime'@'10.20.%'; GRANT INSERT, UPDATE ON osg_account.apple_credentials TO 'osg_account_runtime'@'10.20.%'; @@ -47,11 +53,19 @@ GRANT INSERT ON osg_account.credit_ledger TO 'osg_account_runtime'@'10.20.%'; GRANT INSERT, UPDATE ON osg_account.provider_requests TO 'osg_account_runtime'@'10.20.%'; GRANT INSERT ON osg_account.usage_records TO 'osg_account_runtime'@'10.20.%'; GRANT INSERT, UPDATE ON osg_account.gateway_grants TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT ON osg_account.gateway_grant_scopes TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT, UPDATE ON osg_account.gateway_refresh_tokens TO 'osg_account_runtime'@'10.20.%'; GRANT INSERT, UPDATE ON osg_account.devicecheck_trial_claims TO 'osg_account_runtime'@'10.20.%'; GRANT INSERT, UPDATE ON osg_account.app_attest_challenges TO 'osg_account_runtime'@'10.20.%'; GRANT INSERT, UPDATE ON osg_account.app_attest_keys TO 'osg_account_runtime'@'10.20.%'; GRANT INSERT, UPDATE ON osg_account.account_identity_tombstones TO 'osg_account_runtime'@'10.20.%'; GRANT INSERT, UPDATE ON osg_account.apple_revocation_outbox TO 'osg_account_runtime'@'10.20.%'; +-- Operators and sessions are mutable authentication state. Audit and grant +-- records remain append-only and deliberately receive no UPDATE or DELETE. +GRANT INSERT, UPDATE ON osg_account.admin_operators TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT, UPDATE, DELETE ON osg_account.admin_sessions TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT ON osg_account.admin_audit_log TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT ON osg_account.admin_credit_grants TO 'osg_account_runtime'@'10.20.%'; -- Deliberately absent: global privileges, GRANT OPTION, FILE, PROCESS, SUPER, -- CREATE USER, and UPDATE/DELETE on immutable ledger or usage-history tables. diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index ef17de9..acd6419 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -1,37 +1,8 @@ - {"time":"%date{ISO8601}","level":"%level","logger":"%logger{36}","message":"%replace(%msg){'[\r\n]+',' '}"}%n - - - - - - - - - - - - - - - %date{ISO8601} %-5level [%thread] %logger{24} - %msg%n - - - - - - - - - - - - - - - %d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX} %-5level [%thread] %logger{36} requestId=%X{requestId:-} - %msg%n + + time=%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX} level=%level logger=%logger{36} requestId=%X{requestId:-none} message=%replace(%replace(%msg){'(?i)^.*(?:authorization|cookie|credential|password|secret|token|api[-_ ]?key|apple[-_ ]?subject|audio|prompt|transcript|model[-_ ]?output).*$','[REDACTED]'}){'[\r\n\t ]+','_'}%nopex%n diff --git a/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt b/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt index c521a56..4f1dbd0 100644 --- a/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt +++ b/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt @@ -10,7 +10,7 @@ import java.nio.file.Path class DeploymentConsistencyTest : FunSpec({ val root = Path.of(System.getProperty("user.dir")) - test("OpenAPI documents every mounted public route") { + test("OpenAPI documents every mounted API route") { val openApi = root.read("docs/openapi.yaml") val documentedPaths = Regex("""(?m)^ (/[^:]+):\s*$""") .findAll(openApi) @@ -20,6 +20,25 @@ class DeploymentConsistencyTest : FunSpec({ documentedPaths shouldBe EXPECTED_PUBLIC_PATHS } + test("OpenAPI defines admin pagination and response contracts") { + val openApi = root.read("docs/openapi.yaml") + val sessionSchema = openApi + .substringAfter(" AdminSessionState:") + .substringBefore(" AdminLoginResponse:") + + sessionSchema shouldNotContain "csrfToken" + openApi shouldContain "schema: { \$ref: \"#/components/schemas/AdminOverview\" }" + openApi shouldContain "schema: { \$ref: \"#/components/schemas/AdminReferralOverview\" }" + openApi shouldContain "schema: { \$ref: \"#/components/schemas/AdminUserPage\" }" + openApi shouldContain "schema: { \$ref: \"#/components/schemas/AdminUserDetail\" }" + openApi shouldContain "schema: { \$ref: \"#/components/schemas/AdminLedgerPage\" }" + openApi shouldContain "schema: { \$ref: \"#/components/schemas/AdminAuditPage\" }" + openApi shouldContain "pendingBindings" + openApi shouldContain "ineligibleBindings" + openApi shouldContain "chargedCredits" + openApi shouldContain "referralCode" + } + test("production Compose reuses private MySQL and hardens the application container") { val compose = root.read("compose.yaml") @@ -37,12 +56,35 @@ class DeploymentConsistencyTest : FunSpec({ compose shouldNotContain "0.0.0.0:" } + test("admin bootstrap is one-time and runtime database grants stay explicit") { + val compose = root.read("compose.yaml") + val privileges = root.read("docs/mysql-minimum-privileges.sql") + + compose shouldContain "ADMIN_BOOTSTRAP_ENABLED: \${ADMIN_BOOTSTRAP_ENABLED:-false}" + privileges shouldContain "GRANT SELECT ON osg_account.admin_operators" + privileges shouldContain "GRANT INSERT, UPDATE ON osg_account.admin_operators" + privileges shouldContain "GRANT SELECT ON osg_account.admin_sessions" + privileges shouldContain "GRANT INSERT, UPDATE, DELETE ON osg_account.admin_sessions" + privileges shouldContain "GRANT SELECT ON osg_account.gateway_grant_scopes" + privileges shouldContain "GRANT INSERT ON osg_account.gateway_grant_scopes" + privileges shouldContain "GRANT SELECT ON osg_account.gateway_refresh_tokens" + privileges shouldContain "GRANT INSERT, UPDATE ON osg_account.gateway_refresh_tokens" + privileges shouldContain "GRANT INSERT ON osg_account.admin_audit_log" + privileges shouldContain "GRANT INSERT ON osg_account.admin_credit_grants" + privileges shouldNotContain "UPDATE ON osg_account.admin_audit_log" + privileges shouldNotContain "DELETE ON osg_account.admin_credit_grants" + } + test("container image remains non-root and read-only compatible") { val dockerfile = root.read("Dockerfile") + val build = root.read("build.gradle.kts") dockerfile shouldContain "USER 10001:10001" dockerfile shouldContain "ENV HOME=/tmp" + dockerfile shouldContain "http://127.0.0.1:8080/health/ready" dockerfile shouldNotContain "ENTRYPOINT [\"sh\"" + dockerfile shouldNotContain "jansi.tmpdir" + build shouldContain "exclude(group = \"org.fusesource.jansi\", module = \"jansi\")" } test("OpenResty proxies HTTP WebSocket invitations and both AASA paths safely") { @@ -55,6 +97,7 @@ class DeploymentConsistencyTest : FunSpec({ openResty shouldContain "location ^~ /i/" Regex("""location \^~ /i/ \{\s+access_log off;""").containsMatchIn(openResty) shouldBe true openResty shouldNotContain "alias /www/wwwroot/osglab.com/apple-app-site-association" + openResty shouldNotContain "unsafe-inline" } test("CI definition is singular and leaves MySQL lifecycle to Testcontainers") { @@ -112,6 +155,23 @@ private val EXPECTED_PUBLIC_PATHS = setOf( "/v1/gateway/asr", "/v1/gateway/asr/sessions", "/v1/gateway/asr/sessions/{sessionId}/stream", + "/v1/admin/auth/session", + "/v1/admin/auth/login", + "/v1/admin/auth/logout", + "/v1/admin/overview", + "/v1/admin/referrals", + "/v1/admin/users", + "/v1/admin/users/{userId}", + "/v1/admin/users/{userId}/ledger", + "/v1/admin/credits/grants", + "/v1/admin/operators/summary", + "/v1/admin/operators", + "/v1/admin/operators/{operatorId}/enable", + "/v1/admin/operators/{operatorId}/disable", + "/v1/admin/operators/{operatorId}/unlock", + "/v1/admin/operators/{operatorId}/credentials/reset", + "/v1/admin/operators/{operatorId}/sessions/revoke", + "/v1/admin/audit", "/.well-known/apple-app-site-association", "/apple-app-site-association", "/i/{code}", diff --git a/src/test/kotlin/com/osglab/account/config/LogbackConfigurationTest.kt b/src/test/kotlin/com/osglab/account/config/LogbackConfigurationTest.kt new file mode 100644 index 0000000..79b2920 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/config/LogbackConfigurationTest.kt @@ -0,0 +1,73 @@ +package com.osglab.account.config + +import ch.qos.logback.classic.LoggerContext +import ch.qos.logback.classic.joran.JoranConfigurator +import ch.qos.logback.core.status.Status +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import java.nio.file.Path +import javax.xml.XMLConstants +import javax.xml.parsers.DocumentBuilderFactory +import org.w3c.dom.Element +import org.w3c.dom.Node + +class LogbackConfigurationTest : FunSpec({ + val logbackPath = Path.of(System.getProperty("user.dir"), "src/main/resources/logback.xml") + + test("logback configuration is parseable and has one root") { + val document = secureDocumentBuilderFactory() + .newDocumentBuilder() + .parse(logbackPath.toFile()) + + document.documentElement.tagName shouldBe "configuration" + document.getElementsByTagName("configuration").length shouldBe 1 + document.childElements().map(Element::getTagName) shouldContainExactly listOf("configuration") + } + + test("console pattern is single-line and contains required structured fields") { + val document = secureDocumentBuilderFactory() + .newDocumentBuilder() + .parse(logbackPath.toFile()) + val patterns = document.getElementsByTagName("pattern") + + patterns.length shouldBe 1 + val pattern = patterns.item(0).textContent.trim() + pattern.lines().size shouldBe 1 + listOf("time=", "level=", "logger=", "requestId=", "message=").forEach(pattern::shouldContain) + pattern shouldContain "[REDACTED]" + pattern shouldContain "%nopex" + } + + test("logback accepts the structured pattern without configuration errors") { + val context = LoggerContext() + + try { + JoranConfigurator().apply { this.context = context }.doConfigure(logbackPath.toFile()) + + context.statusManager.copyOfStatusList + .filter { it.level == Status.ERROR } + .shouldBeEmpty() + } finally { + context.stop() + } + } +}) + +private fun secureDocumentBuilderFactory(): DocumentBuilderFactory = + DocumentBuilderFactory.newInstance().apply { + setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) + setFeature("http://xml.org/sax/features/external-general-entities", false) + setFeature("http://xml.org/sax/features/external-parameter-entities", false) + setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "") + setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "") + isXIncludeAware = false + isExpandEntityReferences = false + } + +private fun Node.childElements(): List = + (0 until childNodes.length) + .map(childNodes::item) + .filterIsInstance() diff --git a/src/test/kotlin/com/osglab/account/config/SmokeDeploymentTest.kt b/src/test/kotlin/com/osglab/account/config/SmokeDeploymentTest.kt new file mode 100644 index 0000000..0d64652 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/config/SmokeDeploymentTest.kt @@ -0,0 +1,89 @@ +package com.osglab.account.config + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import java.nio.file.Files +import java.nio.file.Path + +class SmokeDeploymentTest : FunSpec({ + val root = Path.of(System.getProperty("user.dir")) + + test("smoke Compose builds locally and publishes only loopback ports") { + val compose = root.read("compose.smoke.yaml") + + compose shouldContain "image: mysql:8.4" + compose shouldContain "build:" + compose shouldContain "dockerfile: Dockerfile" + compose shouldContain "127.0.0.1:\${SMOKE_APP_PORT" + compose shouldContain "internal: true" + compose shouldContain "network_mode: none" + compose shouldContain "read_only: true" + compose shouldContain "no-new-privileges:true" + compose shouldNotContain "SMOKE_MYSQL_PORT" + compose shouldNotContain "0.0.0.0:" + compose shouldNotContain "ghcr.io/" + } + + test("smoke runner isolates secrets providers and cleanup") { + val runner = root.read("deploy/smoke-local.sh") + + runner shouldContain "set -euo pipefail" + runner shouldContain "mktemp -d" + runner shouldContain "trap cleanup EXIT INT TERM" + runner shouldContain "down --volumes --remove-orphans --rmi local" + runner shouldContain "APPLE_JWKS_URL=http://127.0.0.1:9/" + runner shouldContain "VOLCENGINE_ASR_ENDPOINT=ws://127.0.0.1:9/" + runner shouldContain "DEEPSEEK_ENDPOINT=http://127.0.0.1:9/" + runner shouldContain "Flyway history was not exactly successful V1-V8" + runner shouldContain "first ledger page omitted nextCursor" + runner shouldContain "DELETE FROM admin_sessions WHERE expires_at < UTC_TIMESTAMP()" + runner shouldNotContain "appleid.apple.com" + runner shouldNotContain "api.deepseek.com" + runner shouldNotContain "openspeech.bytedance.com" + } + + test("runtime grants cover every migrated table without mutable history privileges") { + val grants = root.read("deploy/smoke/runtime-grants.sql") + val migrationTables = (1..8) + .flatMap { version -> + val migration = Files.list(root.resolve("src/main/resources/db/migration")).use { paths -> + paths.filter { it.fileName.toString().startsWith("V${version}__") } + .findFirst() + .orElseThrow() + } + CREATE_TABLE.findAll(Files.readString(migration)) + .map { it.groupValues[1] } + .toList() + } + .toSet() + val grantedTables = GRANTED_TABLE.findAll(grants) + .map { it.groupValues[1] } + .toSet() + + grantedTables.sorted() shouldContainExactly migrationTables.sorted() + grants shouldContain "GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.admin_sessions" + grants shouldNotContain "UPDATE ON osg_account_smoke.credit_ledger" + grants shouldNotContain "DELETE ON osg_account_smoke.credit_ledger" + grants shouldNotContain "UPDATE ON osg_account_smoke.admin_audit_log" + grants shouldNotContain "DELETE ON osg_account_smoke.admin_audit_log" + grants shouldNotContain "UPDATE ON osg_account_smoke.admin_credit_grants" + grants shouldNotContain "DELETE ON osg_account_smoke.admin_credit_grants" + } + + test("fixture forces the ledger cursor boundary") { + val fixture = root.read("deploy/smoke/fixture.sql") + + fixture shouldContain "WHERE value < 101" + fixture shouldContain "INSERT INTO credit_ledger" + fixture shouldNotContain "osg_account" + } +}) + +private fun Path.read(relativePath: String): String = + Files.readString(resolve(relativePath)) + +private val CREATE_TABLE = Regex("""CREATE TABLE\s+([a-z0-9_]+)""", RegexOption.IGNORE_CASE) +private val GRANTED_TABLE = Regex("""ON osg_account_smoke\.([a-z0-9_]+)""")