
一个 LLM 中转站手里握着三样东西管理员的后台权限、用户的余额、上游供应商的 API Key。任何一样泄露都是真金白银的损失。这篇文章讲 HeySmart 如何把这三样东西隔离开——三套互不相通的认证体系、两层 RBAC、一条凭据只活在内存的传递链路以及一套宁可丢弃也不反压的审计日志。最后会诚实列出这套设计当前的真实边界哪些威胁防得住哪些防不住。一、先立威胁模型谈安全设计之前先回答一个问题我们在防谁HeySmart 部署在公网上对外的攻击面大致分四类每一类对应的不可信方和防线都不一样。不可信方想干什么第一道防线匿名互联网流量免费用模型、探测系统内部结构网关认证中间件/v1/*、错误消息不泄露内部信息持有合法 appId 的用户超额消费、查看别人的 Key、重放扣费配额扣减、user_id归属过滤、request_id幂等低权限管理员viewer/auditor越权改配置、改他人角色、看上游明文 KeyRBAC 路由权限推断、user:manage仅 super_admin数据库/日志的读取者拖库后直接拿到上游 Key 明文AES-256-GCM 字段加密、json:-、Hash 索引这个模型里有一个容易被忽视的细节日志本身也是攻击面。一个中转站的请求日志如果打印了Authorization头或上游 Key那等于把凭据抄写了一份存在另一个没人盯防的地方。后文会看到 HeySmart 在多处为这一点付出了额外复杂度。还有一个方向上的区分HeySmart 是中转站上游供应商OpenAI、Anthropic 等的 Key 是系统代用户保管的资产泄露一个上游 Key 损失的不是单个用户而是整个账号池的额度。所以上游凭据的防护等级要高于用户自己的 Key。二、三套认证体系为什么是三套而不是一套HeySmart 有三个 HTTP 入口各自服务完全不同的调用方代码里是三套独立实现┌────────────────────────────────────┐ OpenAI SDK ──────────► /v1/* API Key (appId:secret) Anthropic SDK ───────► /v1/messages x-api-key 优先Bearer 回落 │ 同一 DBAuthenticator gateway/server.go:114-131 admin 前端 ──────────► /admin/api/v1/* Admin JWT (HS256) auth/admin/ client 前端 ─────────► /api/v1/* 用户 JWT (HS256) clientapi/jwt.go 支付网关回调 ─────────► /notify/zpay 无 JWTRSA 验签 plugins/payment-zpay └────────────────────────────────────┘为什么不做统一认证因为三套体系的信任来源不同网关 API Key 是给程序用的——SDK 每次请求都带凭据不需要登录态且必须能直接填进 OpenAI SDK 的api_key字段所以格式是Bearer {appId}:{secret}一整串auth/keys.go的包注释专门强调了这个兼容性约束。Admin JWT 和用户 JWT 是给人用的浏览器会话——需要登出、刷新、封禁这些状态机语义对每请求认证的 API Key 是负担。支付回调的调用方是 ZPay 网关——它不会持有你的 JWT secret唯一能验证它身份的是它自己的 RSA 私钥签名。三套体系连签发对象都存在不同的表里Admin JWT 对应admin_usersinternal/adminconsole/models/user.go:38用户 JWT 对应usersinternal/store/models.go:10API Key 对应api_keys。管理员账号和用户账号物理隔离——用户表的任何注入或泄露都碰不到管理面。2.1 网关认证appId:secret 的完整生命周期API Key 的格式是ak-前缀 12 位随机段作 appIdsecret 是 32 字节随机数的 hex64 字符见internal/auth/keys.go:14-41。库里的存储形态只有哈希// internal/store/models.go:31-38typeAPIKeystruct{AppIDstringgorm:...uniqueIndex... json:app_id// ak-xxxx 明文SecretHashstringgorm:type:char(64);not null json:-// SHA-256 hex认证用SecretEncryptedstringgorm:type:text json:-// AES-256-GCM前端查看用SecretPrevHash*stringgorm:type:char(64) json:-// 上一代 secret轮换过渡RotatedAt*time.Timejson:rotated_at,omitemptyRevokedboolgorm:not null;default:false json:revoked...}认证流程在internal/auth/authenticator.go:35的DBAuthenticator.Verify值得逐行看varkey store.APIKeyiferr:a.db.WithContext(ctx).Where(app_id ?,appID).First(key).Error;err!nil{// 不区分不存在与校验失败避免向探测者泄露 appId 存在性returnnil,interfaces.ErrAuthentication(invalid api key)}ifkey.Revoked{returnnil,interfaces.ErrAuthentication(api key has been revoked)}hash:HashSecret(secret)switch{casehashkey.SecretHash:// 当前 secret 通过casekey.SecretPrevHash!nilhash*key.SecretPrevHasha.prevStillValid(key.RotatedAt,now):// 过渡期内旧 secret 通过default:returnnil,interfaces.ErrAuthentication(invalid api key)}// B13: 检查关联用户状态确保用户未被删除/禁用varuser store.Useriferr:a.db.WithContext(ctx).Where(id ?,key.UserID).First(user).Error;err!nil{returnnil,interfaces.ErrAuthentication(user not found or deleted)}ifuser.Status!active{returnnil,interfaces.ErrAuthentication(user account has been disabled)}三个设计点错误消息不泄露存在性。appId 查不到和 secret 错了返回同一句invalid api key探测者无法通过错误差异枚举有效 appId。keys.go:57的ParseCredential同样把所有格式错误统一成一个invalid api key连appId 必须以 ak- 开头这种前缀规则都不对外泄露。轮换有过渡期。Rotateauth/lifecycle.go:74换发 secret 时把旧哈希存进secret_prev_hash默认 24 小时config.go:308的auth.rotate_grace_seconds默认 86400内新旧 secret 都能用——生产服务滚动更新配置时不会出现一半实例用新 Key 一半用旧 Key切换瞬间认证全挂。应急场景用keygen --rotate ak-xxx --expire-prev直接清空 prev hash旧 secret 立即作废。用户状态连带检查B13。Key 本身没吊销但所属用户被禁用/删除认证照样拒绝。没有这一步封禁一个用户后他的 Key 还能继续烧配额。secret 比对用的是无盐 SHA-256。这在一般的密码存储语境下是反模式但这里前提不同secret 是 32 字节256 位密码学随机数不是人写的低熵密码暴力穷举空间是 2^256加盐的意义防彩虹表、防跨用户同密码关联在纯随机凭据面前不存在。SHA-256 换来的是认证路径上的零依赖、恒定开销且数据库有唯一索引可用。这是一个威胁模型决定方案的例子——同一套代码换个语境存用户密码就是漏洞。2.2 Anthropic 端点同一凭据两种头/v1/messages是给 Anthropic SDK 用的Anthropic 的约定是x-api-key头而不是 Bearer。internal/gateway/anthropic_middleware.go:15做了一个很薄的适配funcanthropicCredential(r*http.Request)string{ifk:r.Header.Get(X-Api-Key);k!{returnk}ifcred,err:auth.ParseBearerHeader(r.Header.Get(Authorization));errnil{returncred}return}提取头之后走的是同一个DBAuthenticator.Verify身份注入也用同一个interfaces.WithIdentity。也就是说换的只是门上的锁孔形状锁芯没换——两套端点的认证语义、轮换语义、封禁语义完全一致不会出现 OpenAI 端点封了号而 Anthropic 端点还能进的情况。2.3 Admin JWTtoken_type 区分与内存黑名单Admin JWT 用 HS256 对称签名secret 来自admin_auth.jwt_secret环境变量JWT_SECRET注入config.go:412强制至少 32 字节。Access/Refresh 双 token 的 claims 结构在internal/auth/admin/jwt.go:15typeJWTClaimsstruct{UserIDstringjson:user_idEmailstringjson:emailRolestringjson:role// B10: JWT 中必须包含用户角色TokenTypestringjson:token_type// B7: access | refreshjwt.RegisteredClaims}token_type字段解决一个经典 JWT 漏洞如果不区分拿到短命 Access Token 的攻击者可以把它塞进 refresh 接口换一对新 token把 15 分钟的窗口无限续期。ValidateRefreshTokenjwt.go:124显式校验claims.TokenType ! refresh则拒绝。登出的实现是进程内内存黑名单auth/admin/blacklist.gomap[string]time.Time记录被撤销的 token 及其自然过期时间time.AfterFunc每DefaultCleanupInterval1 小时router.go:20清一轮过期项。这个选择有明确代价——重启丢黑名单。登出后服务一重启被撤销的 refresh token 又能用了直到它自然过期。为什么可接受因为 admin 是单实例部署、实例重启本身极罕见且 access token 只有 30 分钟寿命重启后的暴露窗口有硬上限。换成多实例部署这就是必须改造的点。还有一个容易忽略的细节登出接口auth/admin/handlers.go:317的LogoutHandler对 token 只做ParseUnverified解析不验签任何人拿任意字符串都能调登出。这看起来是漏洞实际是取舍黑名单的键是 token 字符串本身塞进去一个无效 token 只污染 map 一个条目不产生安全收益上的不对称——攻击者无法用它把别人的有效 token踢下线因为他根本拿不到那个 token 字符串。2.4 用户 JWT与 Admin 共享密钥靠 issuer 隔离用户端 JWT 服务在internal/clientapi/jwt.go:68构造时复用了 Admin 的签名密钥// cmd/heysmart/main.go:688clientJWTService:clientapi.NewClientJWTService(cfg.AdminAuth.JWTSecret,cfg.ClientAuth)两套体系同一个JWT_SECRET会不会出现用户 token 拿去调 admin 接口不会因为 claims 结构不同admin 的JWTClaims有role和token_type字段用户的ClientJWTClaimsclientapi/jwt.go:53只有user_id/email且 issuer 是heysmart-clientadmin 是heysmart。admin 中间件按 admin 的 claims 结构解析字段缺失/issuer 不符都验证失败。但这个设计确实依赖两套 claims 结构永远不收敛——如果哪天有人把两边的 claims 结构统一了这个隔离就静默消失。这是需要写进代码评审清单的隐性约束。用户端中间件有一个 admin 端没有的检查clientapi/jwt.go:165的JWTAuthMiddleware每次请求查一次users.status。注释里写明了动机——软删除的用户即使 token 没过期也立即拒绝否则注销后的旧 access token 在有效期内仍可访问受保护路由。代价是每个认证请求多一次点查换来的是封禁即时生效这个运营刚需。对比 admin 端admin 的 access token 验证是纯无状态的禁用一个管理员后他手里的 token 在过期前仍然有效refresh 时才被状态检查拦住——两边策略不一致是当前真实的差异。三、RBAC从角色字符串到路由权限串3.1 五级角色与权限矩阵角色层级定义在internal/auth/admin/rbac.go:217与架构规划文档写的一致varroleHierarchymap[string]int{RoleSuperAdmin:100,RoleAdmin:80,RoleUser:60,RoleViewer:40,RoleAuditor:20,}但层级分数在当前代码里只用于IsRoleHigherOrEqual比较没有任何调用方全仓 grep 仅测试文件引用——真正干活的是rbac_permissions.go:81的权限矩阵。每个角色持有一组resource:action权限串CheckPermission按串精确匹配角色权限范围关键差异super_admin全部CheckPermission顶部直接 bypassrbac.go:179不查矩阵admin全部资源的 read/create/update/delete不含user:manage——不能管理管理员账号useruser/apikey/quota/usage/config/org 的读 部分写业务预留角色viewer全部资源的 read只读auditorusage/report/audit/billing 四个 read只看报表与审计user:manage的分配值得单独说这是唯一一个 admin 角色拿不到的权限专门留给/admin-users路由管理员账号列表、角色调整。也就是说 super_admin 之下不存在能制造新 super_admin的角色——权限提升只有 super_admin 本人一条路。角色调整接口还有两道闸auth/admin/admin_users.go:53白名单校验可分配的角色只有 super_admin/admin/viewer/auditoruser作为业务预留角色不开放分配和禁止修改自己的角色防止最后一个超管自降级后无人能恢复。3.2 路由权限推断为什么不能事后读路由模式RBAC 最容易做错的地方是这个请求需要什么权限的判定。HeySmart 的方案在internal/auth/admin/rbac_route.go手工维护一张「路由模式 → 资源」映射表约 120 条覆盖 adminconsole 全部受保护路由中间件按请求的挂载相对路径逐段匹配再由 HTTP 方法推断动作GET→read、DELETE→delete、PUT/PATCH→update、POST 含参数段→update、集合根 POST→create。为什么不用 chi 自带的RoutePattern()文件头注释讲了一段实战教训RBAC 中间件在路由匹配之前执行chi 的 Use 中间件包裹 routeHTTP此时 RoutePattern 只有挂载通配段/admin/api/v1/*具体模式要等 next.ServeHTTP 后才有。操作审计中间件后文是事后读的因为它只需要在响应后落日志RBAC 必须在放行之前判定所以只能自己维护映射。这张表有一个显式声明的默认放行策略映射不到的模式返回空串放行与历史行为一致默认不受控——新路由没登记进表就不会被 RBAC 拦截。这是一个 fail-open 的选择代码注释没有回避它。配套的纪律是加新路由必须同步登记adminRouteResources映射表的覆盖范围靠rbac_route_test.go与真实路由对照来守。映射表之外还有一个特例/admin-users的所有方法统一要求user:manage因为它不适用GET 就是读、PUT 就是改的通用推断——查看管理员列表本身就是敏感操作。中间件装配时auth/admin/router.go:50-52的注释还记录了一个已实证并修复的漏洞这里必须用 chi 的With内联中间件而不是Route子路由因为Route经Mount剥离挂载段后中间件内 RoutePath 只剩/{id}/role路由推断失配会静默放行——曾经 viewer 可以改他人角色。3.3 权限填充的位置服务端不是客户端RBACMiddlewareForAdminauth/admin/admin.go:71验证 JWT 后做了一件关键的事identity:Identity{UserID:claims.UserID,Email:claims.Email,Role:claims.Role,}ctx:WithAdminIdentity(r.Context(),identity)identity.Permissionsrbac.GetPermissionsByRole(ctx,claims.Role)权限列表不进 JWT。JWT 里只有角色权限在每次请求时由服务端从矩阵实时查出。对比早期版本adminconsole/middleware/rbac.go:69的注释B11: 移除客户端 header 覆盖服务端推断逻辑这个演进的方向很清楚权限的判断依据必须全部来自服务端客户端声称的任何身份信息都不可信。权限不进 JWT 还带来一个运营收益——调整角色权限矩阵立即生效不用等所有管理员的手里 token 过期换发。四、凭据加密三个存储域一套加密服务系统里有三处需要存以后还要用的敏感凭据全部走 AES-256-GCM存储域字段加密内容读取方用户 API Keyapi_keys.secret_encrypted完整appId:secretclientapiRevealKey用户本人查看上游账号池accounts.api_key_encrypted上游供应商 API Key动态路由ResolveModel每请求解密NewAPI 同步令牌accounts.newapi_access_token_encryptedNewAPI 访问令牌配额同步器加密服务实现在internal/adminconsole/services/encryption.go——标准 AES-256-GCM随机 nonce 前置拼接密文再 base64。密钥从环境变量ENCRYPTION_KEY读取强制 32 字节。clientapi 侧有一份等价实现clientapi/jwt.go:275的EncryptionSvc两处代码相同但独立存在——因为两个包不能互相 import架构边界这是可接受的重复前提是改一处必须同步另一处。GCM 模式在这里不是随便选的它自带完整性校验认证标签密钥错误或密文被篡改时gcm.Open直接失败不存在解出一个乱码继续用的路径。密文无 IV 重用风险——每次Encrypt都rand.Reader取新 nonce。4.1 preview 回显与 json:“-” 的双保险凭据的 API 回显遵循一条统一规则列表页给尾巴详情不给全文。// internal/adminconsole/services/encryption.go:93funcGeneratePreview(keystring)string{iflen(key)8{returnsk-****}returnsk-key[len(key)-4:]}accounts表专门有api_key_preview列adminconsole/models/account.go:18创建/更新账号时同步写入handlers/accounts.go:393。Admin 列表接口返回MaskedKey永不返回APIKeyEncrypted——那个字段挂了json:-APIKeyEncryptedstringgorm:type:text;not null json:-APIKeyPreviewstringgorm:type:varchar(64) json:api_key_previewjson:-是 Go 侧的兜底即使某天有人直接把 GORM 模型序列化成响应密文也出不去。同样的标记覆盖了所有哈希字段SecretHash、SecretPrevHash、PasswordHash、RefreshTokenBlacklist.TokenHash。修改语义上还有一条更新账号时 NewAPI 令牌留空 保留存量handlers/accounts.go:562-588只在req.NewAPIAccessToken ! 时才写新密文避免编辑表单没填密钥就把密钥清空的经典事故。用户端查看完整 Key 的接口clientapi/keys.go:252的RevealKey有四道检查JWT 身份存在、id ? AND user_id ?归属过滤、revoked false、SecretEncrypted非空功能上线前创建的旧 Key 没有密文明确提示重新创建而不是报错。解密后全文返回——这是产品需求用户要复制 Key 去配置 SDK风险控制靠它是一个显式的、用户主动触发的动作而非列表页默认输出。值得指出的是这里存在一个有意的安全取舍secret_encrypted让查看完整 Key成为可能意味着拖库 拿到ENCRYPTION_KEY 拿到全部用户 Key 明文。如果只存哈希彻底不可逆产品就做不了找回 Key。HeySmart 选择了产品能力把防护重心放在ENCRYPTION_KEY与数据库凭据的隔离上。上游账号池 Key 也是同样取舍——它必须可逆因为每个请求都要拿明文去调上游。4.2 RouteTarget凭据只活在 ctx 里上游凭据从解密到使用的完整链路是这套安全设计里最核心的一条红线。解密发生在动态路由命中的瞬间internal/adminconsole/services/resolver.go:50ifr.encnil{returnnil,interfaces.ErrInternal(路由失败加密服务不可用无法解密账号凭据)}apiKey,err:r.enc.Decrypt(account.APIKeyEncrypted)iferr!nil{returnnil,interfaces.ErrInternal(路由失败账号凭据解密失败)}returninterfaces.RouteTarget{...APIKey:apiKey,// 解密后的上游凭据...}返回的RouteTarget由内核写进请求 ctxinternal/kernel/route.go:184调interfaces.WithRouteTarget(ctx, target)契约的注释就是这条红线pkg/interfaces/routing.go:1-17// 动态模型路由契约内核经 ModelResolver 把对外模型名解析为// 组成员 账号凭据凭据只经 ctx 内存传递禁止落日志。typeRouteTargetstruct{...APIKeystring// 解密后的上游凭据仅 ctx 内存传递禁止写日志ctx 的键是私有空结构体类型routeTargetCtxKey{}——包外无法构造这个键也就无法在别处伪造或意外读取。驱动构造上游请求时用RouteTargetFromContext(ctx)取出用完随请求结束被 GC。整条链路上明文 Key 的生命周期被压到最短解密 → ctx → 构造上游 HTTP 头 → 请求结束不落库、不落日志、不进响应。把这条链画成图accounts 表 Resolver (adminconsole/services) 内核 ┌──────────────────┐ 选中成员 ┌────────────────────┐ RouteTarget ┌─────────────────┐ │ api_key_encrypted│ ──────────► │ enc.Decrypt(...) │ ──────────────► │ WithRouteTarget │ │ (AES-256-GCM) │ │ 解密后立即使用 │ 含明文 APIKey │ 写入请求 ctx │ └──────────────────┘ └────────────────────┘ └────────┬────────┘ │ 驱动从 ctx 取出 ▼ 构造上游请求头唯一消费点 请求结束 → ctx 释放 → 明文消失对比一个常见反模式把解密后的 Key 缓存在内存 map 里性能好每个账号只解密一次。HeySmart 没这么做——每个请求解密一次的代价换来的是明文永无驻留进程 dump、内存分析都拿不到一份Key 明文缓存。这与根 AGENTS.md §4 的冻结条款一致上游凭据只经 interfaces.RouteTarget 在 ctx 中传递任何情况下不得写日志、不得回显 API。4.3 无认证的支付回调RSA 是唯一防线POST /notify/zpay没有任何 token——它的调用方是支付网关的服务器唯一能验证身份的是 RSA 签名。plugins/payment-zpay/notify.go的处理顺序是原始 body 字段验签signer.VerifyNotify→ 类型过滤只处理 pay→ 状态过滤只处理 SUCCESS→biz_order_no解析为 UUID →creditor.Credit入账。验签失败的注释写得斩钉截铁验签失败拒绝绝不入账。只记 order/notify id 便于排查不记 body——连回调原文都不进日志因为原文里可能被塞过攻击者的探测 payload。金额校验在creditor.Credit内部通知金额必须严格等于订单金额不匹配即CreditRejected不入账但 ack 停止重试。入账用 CAS 事务保证幂等重复回调返回CreditDuplicate瞬时 DB 故障回 500 触发网关重试且回滚事务订单保持 pending。五、审计日志宁可丢不可拖5.1 三个审计面系统的审计分三路各有独立的写入路径审计面触发点落表管理操作adminconsole 全部写方法POST/PUT/PATCH/DELETEoperation_logs登录事件admin 的 login/logout/refresh含失败与原因admin_login_logs数据面流水API 调用、限流决策、failover 切换api_call_logs等管理操作审计的入口是internal/adminconsole/handlers/operation_log_middleware.go:26的OperationLogMiddleware注册在 JWT RBAC之后adminconsole/router.go:76-79——顺序是硬约束它依赖 JWT 注入的AdminIdentity操作人是谁和 RBAC 放行后的路由上下文操作对象是什么。中间件用一个包装 ResponseWriter 捕获状态码和响应体前 512 字节失败原因动作与资源由 chi 路由模式推导登录类操作还会记录 ClientIP 和 UserAgent。5.2 异步写入器的三级防御落库侧的聚合写入器在internal/adminconsole/services/audit_logger.go它的设计目标是注释里的一句话避免高并发下审计写入与业务请求争抢 DB 连接池导致超时雪崩。实现是固定 worker 池默认 2 个 带缓冲队列默认 512每层都有明确的失败语义业务请求 ──enqueue──► [队列 512] ──worker×2──► INSERT3s 超时 │ │ 满时丢弃 计数 失败仅 warn 日志 不反压业务 panic 被 recover 隔离三个防御层级audit_logger.go:117-148入队非阻塞enqueueselect带default分支队列满立即丢弃并累加dropped计数——审计是旁路永远不能反压主链路。单条落库 3 秒超时慢查询不会让 worker 卡死积压传导为丢弃而不是延迟。单条 panic 隔离一条坏数据如非法 UTF-8只损失自己worker 继续消费。这里需要诚实说明一个与架构规划文档的漂移点规划 9.5 写的是事件总线Publish异步记录而当前实现中数据面审计走的是内核AuditEmitter接口kernel.go:50桥接到这个 worker 池audit_logger.go:286的EmitAPICall不是eventbus 的Publish。事件总线确实存在且支持异步PublishAt-Most-Once见kernel/eventbus/eventbus.go:194插件热更新等控制面事件走它但审计写入选择了一条更短的专用通道——不经过订阅分发直接进队列。猜测原因审计的消费者永远只有一个落库走发布订阅是绕路。规划描述的是当时的意图代码演进成了更直接的结构。另一个必须写进文档的语义这套审计是 At-Most-Once不是 At-Least-Once。队列满、进程崩溃、落库失败审计条目都会丢唯一的痕迹是Dropped()计数器和一条 warn 日志。对于运营统计用途这够了但如果未来要拿它做合规取证“每一条管理操作必须可追溯”这个语义是不够的——需要引入落库前的持久化队列或 EventStore。当前取舍的合理性在于审计写入拖垮管理操作或 API 请求损失大于偶尔丢一条日志。登录日志auth/admin/login_log.go是独立的一小段每次登录/刷新/登出含失败起一个带 3 秒超时的 goroutine 写admin_login_logs失败仅 warn。失败原因token_revoked、user_not_found、account_disabled单独成列——登录失败的模式本身就是攻击信号。六、这条防线现在防不住什么前文讲的多是防住了什么这一节讲边界。以下都是当前代码的真实状态不是待办清单——每一项都是在特定威胁模型下的主动取舍或已知缺口。内存黑名单与多实例。Admin 与用户端的 refresh token 黑名单都在进程内存里auth/admin/blacklist.go、clientapi/jwt.go:212。单实例部署下工作正常一旦水平扩展登出只对收到请求的那个实例生效其他实例仍然认这个 token。同理重启丢黑名单。改造方向是把黑名单挪到 Redis系统已有连接代价是每次 refresh 多一跳网络。无状态 access token 的封禁延迟。admin 的 access token 验证不查数据库禁用管理员后他的 token 在剩余有效期内最长 30 分钟仍能通过认证。用户端用每请求查 status换到了即时封禁admin 没做这个交换——管理面流量低做这个检查的边际收益小但语义上两套体系不一致是事实。RBAC 映射表的 fail-open 默认。未登记进adminRouteResources的新路由不受 RBAC 控制推断返回空串放行。这是历史行为的延续代码注释明确声明了它。防线是测试对照rbac_route_test.go与加路由必须登记的开发纪律——机制上没有强制。HS256 单密钥的旋转困难。Admin 与用户 JWT 共用一个JWT_SECRET轮换密钥意味着两套体系同时失效、所有在线会话掉线。没有 kid 机制支持多密钥并存过渡。对当前规模可接受规模变大后是运营负担。ENCRYPTION_KEY的单点。三处凭据密文共用一把密钥密钥与数据库在一起同一环境/同一部署面就等于没加密——它防的是只拿到数据库备份的场景。密钥轮换没有工具支持换密钥需要全量重加密三张表的字段。审计 At-Most-Once。上文已述统计可用取证不够格。这些边界里多数在单实例、小团队的当前部署形态下是合理的取舍。它们变成问题的触发条件也很清晰多实例部署黑名单、合规审计需求审计语义、团队规模扩大RBAC fail-open、密钥管理。安全设计不是达到某个终态而是让每个缺口都有明确的什么时候必须补的答案——这篇文章把它们写下来就是为了让那个时刻到来时有人知道去哪里看。结语隔离是手段最小暴露面是原则回看整套设计三套认证体系把三类不可信方挡在三扇不同的门外RBAC 让谁能按哪个按钮成为服务端单方面的判定AES-256-GCM 加上 ctx 传递让凭据明文的生存期被压缩到一次请求之内审计日志用宁可丢不可拖的旁路设计换来了主链路的稳定。贯穿其中的不是某个加密算法而是一条朴素原则每类敏感信息只在必须出现的地方出现只存活必须存在的时间。secret 存哈希不存明文、上游 Key 解密后不驻留、错误消息不泄露存在性、权限不进 token、审计不记 body——这些决策指向同一件事把暴露面一寸一寸地砍下去。安全设计没有一劳永逸只有持续地把哪里还暴露着回答清楚。感兴趣的朋友可以先看看我们的传送门 HeySmart下一步凭据安全解决了钥匙放哪的问题而每把钥匙能开多少量的门是另一个维度——请关注本系列的计费与配额篇看三池模型如何在资金层面给每个身份划出精确的消费边界。