| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195 |
- package pub
- import (
- "context"
- "errors"
- "time"
- "perms-system-server/internal/consts"
- "perms-system-server/internal/loaders"
- permModel "perms-system-server/internal/model/perm"
- "perms-system-server/internal/svc"
- "perms-system-server/internal/util"
- "github.com/zeromicro/go-zero/core/logx"
- "github.com/zeromicro/go-zero/core/stores/sqlx"
- "golang.org/x/crypto/bcrypt"
- )
- type SyncPermsResult struct {
- Added int64
- Updated int64
- Disabled int64
- }
- type SyncPermItem struct {
- Code string
- Name string
- Remark string
- }
- type SyncPermsError struct {
- Code int
- Message string
- }
- func (e *SyncPermsError) Error() string {
- return e.Message
- }
- func ExecuteSyncPerms(ctx context.Context, svcCtx *svc.ServiceContext, appKey, appSecret string, perms []SyncPermItem) (*SyncPermsResult, error) {
- product, err := svcCtx.SysProductModel.FindOneByAppKey(ctx, appKey)
- if err != nil {
- return nil, &SyncPermsError{Code: 401, Message: "无效的appKey"}
- }
- if err := bcrypt.CompareHashAndPassword([]byte(product.AppSecret), []byte(appSecret)); err != nil {
- return nil, &SyncPermsError{Code: 401, Message: "appSecret验证失败"}
- }
- if product.Status != consts.StatusEnabled {
- return nil, &SyncPermsError{Code: 403, Message: "产品已被禁用"}
- }
- if len(perms) == 0 {
- return nil, &SyncPermsError{Code: 400, Message: "权限列表不能为空"}
- }
- // 去重请求列表,避免同一笔同步里 codes 互相冲突。
- codes := make([]string, 0, len(perms))
- seen := make(map[string]bool, len(perms))
- dedupPerms := make([]SyncPermItem, 0, len(perms))
- for _, item := range perms {
- if seen[item.Code] {
- continue
- }
- seen[item.Code] = true
- codes = append(codes, item.Code)
- dedupPerms = append(dedupPerms, item)
- }
- now := time.Now().Unix()
- var added, updated, disabled int64
- // 同 tx 内先 SELECT ... FOR UPDATE 锁 sys_product 行,再在 tx 内读取 existing 并写入:
- // 把同一 product 的并发同步串行化,避免两次同步都认为 code X 不存在并并发 INSERT 撞
- // sys_perm UNIQUE(productCode, code) 拿 1062(见审计 H-3)。
- err = svcCtx.SysPermModel.TransactCtx(ctx, func(txCtx context.Context, session sqlx.Session) error {
- locked, err := svcCtx.SysProductModel.LockByCodeTx(txCtx, session, product.Code)
- if err != nil {
- if errors.Is(err, sqlx.ErrNotFound) {
- return &SyncPermsError{Code: 404, Message: "产品不存在"}
- }
- return err
- }
- // 审计 M-R10-1:事务外的 FindOneByAppKey 只是 cache/stale 读,无法感知"UpdateProduct
- // 与本同步并发到达、UpdateProduct 后提交"的时序(SyncPerms 先拿到 X 锁时,UpdateProduct 的
- // UPDATE 会排队等锁,SyncPerms 在锁内读到的 product.Status 仍是 Enabled 但真正提交顺序会
- // 让 product 行最终是 Disabled)。LockByCodeTx 已经拿到行 X 锁,这里对 locked.Status 再做一
- // 次事务内复核,任何已禁用的产品都不得继续写 sys_perm,修复"禁用后同一秒仍生成 perm diff"
- // 把审计告警带偏的问题。安全侧 loadPerms 仍会按 ProductStatus!=Enabled 返空,但运营/审计
- // 链路需要本处兜底避免假象。
- if locked.Status != consts.StatusEnabled {
- return &SyncPermsError{Code: 403, Message: "产品已被禁用"}
- }
- existingMap, err := svcCtx.SysPermModel.FindMapByProductCodeWithTx(txCtx, session, product.Code)
- if err != nil {
- return err
- }
- var toInsert []*permModel.SysPerm
- var toUpdate []*permModel.SysPerm
- for _, item := range dedupPerms {
- existing, ok := existingMap[item.Code]
- if !ok {
- toInsert = append(toInsert, &permModel.SysPerm{
- ProductCode: product.Code,
- Name: item.Name,
- Code: item.Code,
- Remark: item.Remark,
- Status: consts.StatusEnabled,
- CreateTime: now,
- UpdateTime: now,
- })
- added++
- continue
- }
- if existing.Name != item.Name || existing.Remark != item.Remark || existing.Status != consts.StatusEnabled {
- existing.Name = item.Name
- existing.Remark = item.Remark
- existing.Status = consts.StatusEnabled
- existing.UpdateTime = now
- toUpdate = append(toUpdate, existing)
- updated++
- }
- }
- if len(toInsert) > 0 {
- if insertErr := svcCtx.SysPermModel.BatchInsertWithTx(txCtx, session, toInsert); insertErr != nil {
- return insertErr
- }
- }
- if len(toUpdate) > 0 {
- if updateErr := svcCtx.SysPermModel.BatchUpdateWithTx(txCtx, session, toUpdate); updateErr != nil {
- return updateErr
- }
- }
- var disableErr error
- disabled, disableErr = svcCtx.SysPermModel.DisableNotInCodesWithTx(txCtx, session, product.Code, codes, now)
- return disableErr
- })
- if err != nil {
- var se *SyncPermsError
- if errors.As(err, &se) {
- return nil, se
- }
- // 第 6 轮测试报告 §9.5#3:H-3 已经通过 LockByCodeTx 把同一产品的同步串行化,理论上
- // sys_perm (productCode, code) UNIQUE 在事务内不可能再拿到 1062。若真的命中,说明:
- // (a) LockByCodeTx 没有生效(例如引擎/隔离级别被改);
- // (b) 有绕过本函数直接写 sys_perm 的代码路径被引入;
- // 任何一种都代表 H-3 的锁序契约失效,需要立即告警补回 409 重试契约。因此在这里落一条
- // 带 audit=mysql_error_1062 + table=sys_perm 的 ERROR 级日志,日志采集侧即可据此建
- // 指标与告警规则;对外仍然回通用 500 避免给客户端透传 DB 细节。
- if util.IsDuplicateEntryErr(err) {
- logx.WithContext(ctx).Errorw("sync perms hit 1062 under LockByCodeTx — H-3 contract regressed",
- logx.Field("audit", "mysql_error_1062"),
- logx.Field("table", "sys_perm"),
- logx.Field("productCode", product.Code),
- logx.Field("err", err.Error()),
- )
- }
- return nil, &SyncPermsError{Code: 500, Message: "同步权限事务失败"}
- }
- // 审计 L-R11-4 / M-R17-1:任一 added / updated / disabled > 0 都必须清 CleanByProduct。
- //
- // L-R11-4 曾经把"纯新增(added>0 && updated==0 && disabled==0)"从 CleanByProduct 条件里摘掉,
- // 依据是"loadPerms 对当前全体 user 的计算结果与上次结果完全一致"——但这个论断只对**走角色/
- // 授权矩阵路径的普通 MEMBER**成立:他们 loadPerms 走 FindPermIdsByRoleIds + allow/deny 链,
- // 新 perm 没被任何 role 引用,结果集合不会变。
- //
- // M-R17-1 指出漏洞:**全权用户**(SuperAdmin / 本产品 ADMIN / DEVELOPER / DEV 部门启用成员)的
- // loadPerms 走 FindAllCodesByProductCode(productCode) 单条路径,返回该产品下所有 Enabled
- // 的 perm 全集,新增任意 perm 都会让集合变大。跳过 CleanByProduct 等价于这四类用户的新权限
- // 感知延迟最长 5min(UD TTL),发版当天容易出现"超管登录却拉不到 /v2/C"。
- //
- // 采用"全产品一刀切"的保守路径(audit M-R17-1 方案 2):CleanByProduct 的穿透开销在 CI/CD
- // 高频发版场景确实存在,但相比"按 SuperAdmin + ADMIN/DEVELOPER + DEV-dept 精准枚举"需要跨
- // 三张表聚合 userId 批量 Clean(audit 方案 1),实现复杂度与冷启动放大率权衡下,先保方案
- // 2 的正确性;若未来 CleanByProduct 的压测数据表明 Redis/DB 穿透不可承受,再回退到方案 1。
- if added > 0 || updated > 0 || disabled > 0 {
- // 审计 M-R14-1:事务已提交,沿用 request ctx 做 CleanByProduct 会在调用方(pub 入口、
- // CLI 入口等)ctx 被 cancel 时立刻放弃 Redis DEL,留下"本产品所有成员的 UD 缓存仍
- // 携带被禁用 perm"的窗口(最长 5min TTL)。消费方若只看 GetUserPerms 返回、不做
- // checkStillValid 的 DB 复核就会命中失效 perm。Detach 到独立 ctx + 3s 超时,post-commit
- // 的缓存失效独立于请求生命周期。
- cleanCtx, cancel := loaders.DetachCacheCleanCtx(ctx)
- defer cancel()
- svcCtx.UserDetailsLoader.CleanByProduct(cleanCtx, product.Code)
- }
- return &SyncPermsResult{
- Added: added,
- Updated: updated,
- Disabled: disabled,
- }, nil
- }
|