Complete permissions during every bind and token path, expose effective access in the console, and keep the platform return path globally available. Co-authored-by: Cursor <cursoragent@cursor.com>
245 lines
8.0 KiB
Go
245 lines
8.0 KiB
Go
package applogic
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
|
||
"aijianzhan/platform/internal/agentcap"
|
||
"aijianzhan/platform/internal/agentstore"
|
||
"aijianzhan/platform/internal/authx"
|
||
"aijianzhan/platform/internal/dbsync"
|
||
"aijianzhan/platform/internal/schema"
|
||
"aijianzhan/platform/internal/tenantperm"
|
||
"aijianzhan/platform/internal/types"
|
||
"aijianzhan/platform/internal/userstore"
|
||
"aijianzhan/platform/internal/yuhticket"
|
||
)
|
||
|
||
type YuhengTicketExchangeReq struct {
|
||
Ticket string `json:"ticket"`
|
||
HostKey string `json:"host_key"` // 须与票内一致
|
||
LocalDatabaseID string `json:"local_database_id,omitempty"`
|
||
Name string `json:"name,omitempty"`
|
||
}
|
||
|
||
type YuhengTicketExchangeResp struct {
|
||
*types.TokenResp
|
||
OK bool `json:"ok"`
|
||
ClientID string `json:"client_id,omitempty"`
|
||
ClientSecret string `json:"client_secret,omitempty"` // 仅新注册/轮换时返回一次
|
||
Message string `json:"message,omitempty"`
|
||
}
|
||
|
||
// ExchangeYuhengTicket 宇恒专属:验签凭票 → 绑定落点 → 签发智能体 JWT(免智建账号密码登录)。
|
||
func (l *AuthLogic) ExchangeYuhengTicket(req YuhengTicketExchangeReq) (*YuhengTicketExchangeResp, error) {
|
||
cfg := l.svcCtx.Config.Agent.YuhengTicket
|
||
if !cfg.Enabled {
|
||
return nil, fmt.Errorf("宇恒凭票未启用(Agent.YuhengTicket.Enabled)")
|
||
}
|
||
if strings.TrimSpace(cfg.Secret) == "" {
|
||
return nil, fmt.Errorf("宇恒凭票 Secret 未配置")
|
||
}
|
||
if l.svcCtx.Users == nil || l.svcCtx.Agents == nil || l.svcCtx.DBSync == nil {
|
||
return nil, fmt.Errorf("bind service unavailable")
|
||
}
|
||
claims, err := yuhticket.Verify(req.Ticket, yuhticket.VerifyOpts{
|
||
Secret: cfg.Secret,
|
||
Issuer: cfg.Issuer,
|
||
Audience: cfg.Audience,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
hostKey := strings.TrimSpace(req.HostKey)
|
||
if hostKey == "" {
|
||
hostKey = strings.TrimSpace(claims.HostKey)
|
||
}
|
||
if hostKey == "" || hostKey != strings.TrimSpace(claims.HostKey) {
|
||
return nil, fmt.Errorf("host_key 与凭票不一致")
|
||
}
|
||
if l.svcCtx.YuhengJTI != nil {
|
||
if err := l.svcCtx.YuhengJTI.Consume(claims.JTI, claims.Exp); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
phone, err := userstore.NormalizePhone(claims.Phone)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("凭票手机号无效")
|
||
}
|
||
u, err := l.svcCtx.Users.GetByPhone(l.ctx, phone)
|
||
if err != nil || u == nil {
|
||
return nil, fmt.Errorf("无此成员;请先在智建绑定手机或使用绑定码")
|
||
}
|
||
if u.TenantID <= 0 {
|
||
return nil, fmt.Errorf("该账号尚未加入公司")
|
||
}
|
||
name := strings.TrimSpace(req.Name)
|
||
if name == "" {
|
||
name = strings.TrimSpace(claims.Name)
|
||
}
|
||
localID := strings.TrimSpace(req.LocalDatabaseID)
|
||
if localID == "" {
|
||
localID = strings.TrimSpace(claims.LocalDatabaseID)
|
||
}
|
||
acc, err := l.bindUserHostSync(u, hostKey, name, localID, "yuheng-ticket")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// Z12h:换票时顺带自愈本租户挂死通道的 Binding/Agent
|
||
if _, hErr := HealTenantSyncBind(l.ctx, l.svcCtx, u.TenantID); hErr == nil {
|
||
if refreshed, gErr := l.svcCtx.Agents.Get(l.ctx, acc.TenantID, acc.AgentID); gErr == nil && refreshed != nil {
|
||
acc = refreshed
|
||
}
|
||
}
|
||
acc, err = l.ensureYuhengAgentPerms(acc)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 补权成功后才轮换密钥,禁止“绑定成功但业务接口 403”。
|
||
secret, err := l.svcCtx.Agents.RotateSecret(l.ctx, acc.TenantID, acc.AgentID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("rotate client_secret: %w", err)
|
||
}
|
||
token, exp, err := authx.IssueAgentToken(l.svcCtx.JWT, acc.TenantID, acc.AgentID, acc.Perms)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
_ = l.svcCtx.Agents.TouchToken(l.ctx, acc.AgentID)
|
||
capSecret := l.svcCtx.Config.Agent.CapsuleSecret
|
||
if capSecret == "" {
|
||
capSecret = l.svcCtx.JWT.AccessSecret
|
||
}
|
||
tr := &types.TokenResp{
|
||
AccessToken: token,
|
||
TokenType: "Bearer",
|
||
ExpiresAt: exp,
|
||
TenantID: acc.TenantID,
|
||
UserID: acc.AgentID,
|
||
Username: acc.ClientID,
|
||
DisplayName: acc.Name,
|
||
Role: authx.RoleAgent,
|
||
AgentKey: agentcap.PublicAgentKey(capSecret, acc.TenantID, acc.AgentID),
|
||
AgentID: acc.AgentID,
|
||
Permissions: append([]string{}, acc.Perms...),
|
||
AppSlugs: append([]string{}, acc.AppSlugs...),
|
||
}
|
||
fillAgentSyncOnToken(tr, acc)
|
||
_ = l.writeBindAudit("yuheng_ticket_exchange", acc.TenantID, acc.AgentID, map[string]any{
|
||
"phone": phone, "jti": claims.JTI, "yuheng_user_id": claims.YuhengUserID,
|
||
})
|
||
return &YuhengTicketExchangeResp{
|
||
TokenResp: tr,
|
||
OK: true,
|
||
ClientID: acc.ClientID,
|
||
ClientSecret: secret,
|
||
Message: "绑定并授权完成(仅宇恒)",
|
||
}, nil
|
||
}
|
||
|
||
var yuhengAgentPerms = []string{
|
||
authx.Perm读取模块,
|
||
authx.Perm写入模块,
|
||
authx.Perm发布模块,
|
||
authx.Perm查询数据,
|
||
authx.Perm新增数据,
|
||
authx.Perm导入数据,
|
||
authx.Perm上传文件,
|
||
authx.Perm下载文件,
|
||
authx.Perm数据同步,
|
||
}
|
||
|
||
// ensureYuhengAgentPerms 以并集方式补齐建站权限包,保留账号已有权限。
|
||
func (l *AuthLogic) ensureYuhengAgentPerms(acc *agentstore.Account) (*agentstore.Account, error) {
|
||
if acc == nil {
|
||
return nil, fmt.Errorf("agent account unavailable")
|
||
}
|
||
perms := append([]string{}, acc.Perms...)
|
||
seen := make(map[string]struct{}, len(perms)+len(yuhengAgentPerms))
|
||
for _, p := range acc.Perms {
|
||
seen[authx.NormalizePerm(p)] = struct{}{}
|
||
}
|
||
changed := acc.Status != agentstore.StatusActive
|
||
for _, p := range yuhengAgentPerms {
|
||
if _, ok := seen[p]; ok {
|
||
continue
|
||
}
|
||
perms = append(perms, p)
|
||
seen[p] = struct{}{}
|
||
changed = true
|
||
}
|
||
if !changed {
|
||
return acc, nil
|
||
}
|
||
perms = authx.NormalizePerms(perms)
|
||
if err := tenantperm.MustAllow(l.ctx, l.svcCtx.TenantPerm, acc.TenantID, perms); err != nil {
|
||
return nil, fmt.Errorf("绑定授权超出公司权限额度: %w", err)
|
||
}
|
||
st := agentstore.StatusActive
|
||
updated, err := l.svcCtx.Agents.Update(l.ctx, acc.TenantID, acc.AgentID, agentstore.UpdateInput{
|
||
Status: &st,
|
||
Perms: &perms,
|
||
})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("绑定授权失败: %w", err)
|
||
}
|
||
return updated, nil
|
||
}
|
||
|
||
// bindUserHostSync 将 host_key 智能体挂到用户公司默认同步落点并写 Binding。
|
||
func (l *AuthLogic) bindUserHostSync(u *userstore.User, hostKey, name, localID, note string) (*agentstore.Account, error) {
|
||
cfg := l.svcCtx.Config.DBSync
|
||
driver := dbsync.Driver(strings.TrimSpace(cfg.DefaultRemoteDriver))
|
||
if driver == "" {
|
||
driver = dbsync.DriverPostgres
|
||
}
|
||
ch, err := l.svcCtx.DBSync.EnsureAndStartSystemDefaultChannel(dbsync.DefaultChannelOpts{
|
||
TenantID: u.TenantID,
|
||
RemoteDriver: driver,
|
||
RemoteDSN: strings.TrimSpace(cfg.DefaultRemoteDSN),
|
||
})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("ensure channel: %w", err)
|
||
}
|
||
acc, err := l.svcCtx.Agents.FindByHostKey(l.ctx, hostKey)
|
||
if err != nil {
|
||
if name == "" {
|
||
name = "离线终端 · " + maskDisplayName(u.DisplayName)
|
||
}
|
||
created, sec, _, regErr := l.svcCtx.Agents.Register(l.ctx, u.TenantID, name, hostKey)
|
||
if regErr != nil {
|
||
return nil, regErr
|
||
}
|
||
acc = created
|
||
_ = sec
|
||
} else if acc.TenantID != u.TenantID && acc.Status == agentstore.StatusPending {
|
||
// 允许 pending 迁公司;已激活异租户拒绝
|
||
} else if acc.TenantID != u.TenantID {
|
||
return nil, fmt.Errorf("host_key 已绑定其它公司")
|
||
}
|
||
online := fmt.Sprintf("%s_u%d", dbsync.ResolveOnlineDBID("", ch.ID), u.UserID)
|
||
// 中文昵称可接受:写入 Binding 展示字段;物理库名映射为 user_{id} / 哈希(Z35)
|
||
display := strings.TrimSpace(u.DisplayName)
|
||
if display == "" {
|
||
display = fmt.Sprintf("user_%d", u.UserID)
|
||
}
|
||
dbName := schema.SafeSyncDatabaseName(display, acc.AgentID, u.UserID)
|
||
updated, err := l.svcCtx.Agents.AttachSyncBind(l.ctx, acc.AgentID, u.TenantID, ch.ID, online, dbName, true)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if localID == "" {
|
||
localID = "host:" + hostKey
|
||
}
|
||
_, _ = l.svcCtx.DBSync.Store().EnsureBinding(dbsync.Binding{
|
||
TenantID: u.TenantID,
|
||
UserID: u.UserID,
|
||
LocalDatabaseID: localID,
|
||
OnlineDBID: online,
|
||
ChannelID: ch.ID,
|
||
DatabaseName: display, // 可读名(可中文)
|
||
DisplayName: display,
|
||
Note: note,
|
||
})
|
||
return updated, nil
|
||
}
|