0.11.6: 渠道权限管控(部门范围 + 用户级授权)
- 迁移 000047:channels.department_ids(空=全局) + channel_grants 用户级授权 (source=manual/approval 区分来源)。 - 管理端:渠道部门范围配置 + 授权管理(列表/授予/撤销);渠道列表显示范围。 - 门户:我的渠道端点(/api/v1/portal/channels)按部门可见或明确授权返回, 「个人渠道」页新增可使用渠道区(授权方式标识)。 - 审批流:资源申请中的渠道类型通过后自动写 channel_grants(source=approval), 取代'批准记录即授权'的弱语义。 - 端到端验证:部门隔离(demo 无部门看不到)→手动授予→可见→撤销→不可见; 审批通过自动授权。修复 JOIN 列歧义与 uuid/text 比较。
This commit is contained in:
@@ -24,6 +24,9 @@ func NewHTTPHandler(service *Service, identityService *identity.Service) *HTTPHa
|
||||
h.mux.HandleFunc("PUT /api/v1/admin/channels/{id}", h.save)
|
||||
h.mux.HandleFunc("DELETE /api/v1/admin/channels/{id}", h.delete)
|
||||
h.mux.HandleFunc("POST /api/v1/admin/channels/{id}/test", h.test)
|
||||
h.mux.HandleFunc("GET /api/v1/admin/channels/{id}/grants", h.listGrants)
|
||||
h.mux.HandleFunc("POST /api/v1/admin/channels/{id}/grants", h.grant)
|
||||
h.mux.HandleFunc("DELETE /api/v1/admin/channels/{id}/grants/{user_id}", h.revokeGrant)
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -55,13 +58,14 @@ func (h *HTTPHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
type channelInput struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
ModelBinding json.RawMessage `json:"model_binding"`
|
||||
APIKey string `json:"api_key"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
ModelBinding json.RawMessage `json:"model_binding"`
|
||||
DepartmentIDs []string `json:"department_ids"`
|
||||
APIKey string `json:"api_key"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) save(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -87,7 +91,7 @@ func (h *HTTPHandler) save(w http.ResponseWriter, r *http.Request) {
|
||||
if input.Enabled != nil {
|
||||
enabled = *input.Enabled
|
||||
}
|
||||
item, err := h.service.Save(r.Context(), r.PathValue("id"), input.Code, input.Name, input.Kind, cfg, input.ModelBinding, input.APIKey, enabled, actor.ID)
|
||||
item, err := h.service.Save(r.Context(), r.PathValue("id"), input.Code, input.Name, input.Kind, cfg, input.ModelBinding, input.DepartmentIDs, input.APIKey, enabled, actor.ID)
|
||||
if err != nil {
|
||||
apiresponse.Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
@@ -212,3 +216,50 @@ func (h *InboundHTTPHandler) inbound(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
apiresponse.OK(w, map[string]bool{"accepted": true})
|
||||
}
|
||||
|
||||
// listGrants 渠道用户授权列表。
|
||||
func (h *HTTPHandler) listGrants(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.require(w, r, identity.PermissionNotificationRead); !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.service.ListGrants(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
apiresponse.Error(w, http.StatusServiceUnavailable, "渠道授权查询失败")
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, items)
|
||||
}
|
||||
|
||||
// grant 直接授予用户渠道使用权限(管理员显式授权,无需走申请流)。
|
||||
func (h *HTTPHandler) grant(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := h.require(w, r, identity.PermissionNotificationManage)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
PortalUserID string `json:"portal_user_id"`
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if decoder.Decode(&input) != nil || strings.TrimSpace(input.PortalUserID) == "" {
|
||||
apiresponse.Error(w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
if err := h.service.Grant(r.Context(), r.PathValue("id"), input.PortalUserID, actor.ID, "manual"); err != nil {
|
||||
apiresponse.Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, map[string]bool{"granted": true})
|
||||
}
|
||||
|
||||
// revokeGrant 撤销用户的渠道使用权限。
|
||||
func (h *HTTPHandler) revokeGrant(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.require(w, r, identity.PermissionNotificationManage); !ok {
|
||||
return
|
||||
}
|
||||
if err := h.service.RevokeGrant(r.Context(), r.PathValue("id"), r.PathValue("user_id")); err != nil {
|
||||
apiresponse.Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, map[string]bool{"revoked": true})
|
||||
}
|
||||
|
||||
+124
-7
@@ -39,6 +39,7 @@ type Channel struct {
|
||||
EncryptedConfig []byte `json:"-"`
|
||||
ConfigKEKVersion int `json:"-"`
|
||||
ModelBinding json.RawMessage `json:"model_binding"`
|
||||
DepartmentIDs []string `json:"department_ids"`
|
||||
HasAPIKey bool `json:"has_api_key"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy *string `json:"created_by,omitempty"`
|
||||
@@ -84,12 +85,12 @@ func NewService(pool *pgxpool.Pool, gatewayURL string, cipher interface {
|
||||
}
|
||||
}
|
||||
|
||||
const channelSelect = `SELECT id::text,code,name,kind,encrypted_config,config_kek_version,model_binding,octet_length(encrypted_api_key)>0,enabled,created_by::text,created_at,updated_at FROM gateway.channels`
|
||||
const channelSelect = `SELECT id::text,code,name,kind,encrypted_config,config_kek_version,model_binding,department_ids::text[],octet_length(encrypted_api_key)>0,enabled,created_by::text,created_at,updated_at FROM gateway.channels`
|
||||
|
||||
func (s *Service) scan(row pgx.Row) (Channel, error) {
|
||||
var c Channel
|
||||
var createdBy *string
|
||||
err := row.Scan(&c.ID, &c.Code, &c.Name, &c.Kind, &c.EncryptedConfig, &c.ConfigKEKVersion, &c.ModelBinding, &c.HasAPIKey, &c.Enabled, &createdBy, &c.CreatedAt, &c.UpdatedAt)
|
||||
err := row.Scan(&c.ID, &c.Code, &c.Name, &c.Kind, &c.EncryptedConfig, &c.ConfigKEKVersion, &c.ModelBinding, &c.DepartmentIDs, &c.HasAPIKey, &c.Enabled, &createdBy, &c.CreatedAt, &c.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Channel{}, ErrNotFound
|
||||
}
|
||||
@@ -146,7 +147,7 @@ func (s *Service) DecryptConfig(c Channel) (Config, error) {
|
||||
}
|
||||
|
||||
// Save 创建/更新渠道。
|
||||
func (s *Service) Save(ctx context.Context, id, code, name, kind string, cfg Config, modelBinding json.RawMessage, apiKey string, enabled bool, actorID string) (Channel, error) {
|
||||
func (s *Service) Save(ctx context.Context, id, code, name, kind string, cfg Config, modelBinding json.RawMessage, departmentIDs []string, apiKey string, enabled bool, actorID string) (Channel, error) {
|
||||
if s == nil || s.pool == nil || s.cipher == nil {
|
||||
return Channel{}, ErrUnavailable
|
||||
}
|
||||
@@ -182,13 +183,13 @@ func (s *Service) Save(ctx context.Context, id, code, name, kind string, cfg Con
|
||||
if modelBinding == nil {
|
||||
modelBinding = json.RawMessage(`{}`)
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, `INSERT INTO gateway.channels(id,code,name,kind,encrypted_config,config_kek_version,encrypted_api_key,api_key_kek_version,model_binding,enabled,created_by)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
||||
_, err = s.pool.Exec(ctx, `INSERT INTO gateway.channels(id,code,name,kind,encrypted_config,config_kek_version,encrypted_api_key,api_key_kek_version,model_binding,department_ids,enabled,created_by)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||
ON CONFLICT(code) DO UPDATE SET name=$3,kind=$4,encrypted_config=$5,config_kek_version=$6,
|
||||
encrypted_api_key=CASE WHEN $7<>'' THEN $7 ELSE gateway.channels.encrypted_api_key END,
|
||||
api_key_kek_version=CASE WHEN $7<>'' THEN $8 ELSE gateway.channels.api_key_kek_version END,
|
||||
model_binding=$9,enabled=$10,updated_at=clock_timestamp()`,
|
||||
id, code, name, kind, encryptedConfig, configVersion, encryptedKey, keyVersion, modelBinding, enabled, actorID)
|
||||
model_binding=$9,department_ids=$10,enabled=$11,updated_at=clock_timestamp()`,
|
||||
id, code, name, kind, encryptedConfig, configVersion, encryptedKey, keyVersion, modelBinding, departmentIDs, enabled, actorID)
|
||||
if err != nil {
|
||||
return Channel{}, err
|
||||
}
|
||||
@@ -440,3 +441,119 @@ func DingSign(timestamp int64, secret string) string {
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%d\n%s", timestamp, secret)))
|
||||
return url.QueryEscape(hex.EncodeToString(sum[:]))
|
||||
}
|
||||
|
||||
// ChannelGrant 是一条用户级渠道授权。
|
||||
type ChannelGrant struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
ChannelCode string `json:"channel_code"`
|
||||
ChannelName string `json:"channel_name"`
|
||||
UserID string `json:"portal_user_id"`
|
||||
UserLogin string `json:"user_login"`
|
||||
Source string `json:"source"`
|
||||
GrantedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListGrants 返回渠道的用户授权列表。
|
||||
func (s *Service) ListGrants(ctx context.Context, channelID string) ([]ChannelGrant, error) {
|
||||
if s == nil || s.pool == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `SELECT g.channel_id::text,c.code,c.name,g.portal_user_id::text,u.account,g.source,g.created_at
|
||||
FROM gateway.channel_grants g JOIN gateway.channels c ON c.id=g.channel_id JOIN gateway.portal_users u ON u.id=g.portal_user_id
|
||||
WHERE g.channel_id=$1 ORDER BY g.created_at DESC`, channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ChannelGrant{}
|
||||
for rows.Next() {
|
||||
var item ChannelGrant
|
||||
if err := rows.Scan(&item.ChannelID, &item.ChannelCode, &item.ChannelName, &item.UserID, &item.UserLogin, &item.Source, &item.GrantedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// Grant 授予用户渠道使用权限(幂等)。source: manual(管理员直接授予)或
|
||||
// approval(资源申请审批通过自动写入)。
|
||||
func (s *Service) Grant(ctx context.Context, channelID, portalUserID, actorID, source string) error {
|
||||
if s == nil || s.pool == nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
if source != "manual" && source != "approval" {
|
||||
source = "manual"
|
||||
}
|
||||
var exists bool
|
||||
if err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.portal_users WHERE id=$1)`, portalUserID).Scan(&exists); err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return ErrNotFound
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO gateway.channel_grants(channel_id,portal_user_id,granted_by,source) VALUES($1,$2,nullif($3,'')::uuid,$4) ON CONFLICT DO NOTHING`, channelID, portalUserID, actorID, source)
|
||||
return err
|
||||
}
|
||||
|
||||
// RevokeGrant 撤销用户的渠道使用权限。
|
||||
func (s *Service) RevokeGrant(ctx context.Context, channelID, portalUserID string) error {
|
||||
if s == nil || s.pool == nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM gateway.channel_grants WHERE channel_id=$1 AND portal_user_id=$2`, channelID, portalUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GrantsForUser 返回用户有明确授权的渠道(供门户"我的渠道")。
|
||||
func (s *Service) GrantsForUser(ctx context.Context, portalUserID string) ([]Channel, error) {
|
||||
if s == nil || s.pool == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `SELECT c.* FROM (`+channelSelect+`) c JOIN gateway.channel_grants g ON g.channel_id=c.id::uuid AND g.portal_user_id=$1::uuid WHERE c.enabled ORDER BY c.updated_at DESC`, portalUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Channel{}
|
||||
for rows.Next() {
|
||||
c, err := s.scan(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, c)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// VisibleChannelsForUser 返回用户可见渠道:部门可见(全局或本部门)或明确授权。
|
||||
func (s *Service) VisibleChannelsForUser(ctx context.Context, portalUserID string, departmentID *string) ([]Channel, error) {
|
||||
if s == nil || s.pool == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `SELECT c.* FROM (`+channelSelect+`) c
|
||||
WHERE c.enabled AND (
|
||||
cardinality(c.department_ids)=0
|
||||
OR ($2::text = ANY(c.department_ids))
|
||||
OR EXISTS(SELECT 1 FROM gateway.channel_grants g WHERE g.channel_id=c.id::uuid AND g.portal_user_id=$1::uuid)
|
||||
) ORDER BY c.updated_at DESC`, portalUserID, departmentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Channel{}
|
||||
for rows.Next() {
|
||||
c, err := s.scan(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, c)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ func NewHTTPHandler(service *Service, identityService *identity.Service) *HTTPHa
|
||||
h.mux.HandleFunc("POST /api/v1/portal/personal-channels", h.createPersonalChannel)
|
||||
h.mux.HandleFunc("POST /api/v1/portal/personal-channels/{id}/token", h.regeneratePersonalToken)
|
||||
h.mux.HandleFunc("DELETE /api/v1/portal/personal-channels/{id}", h.deletePersonalChannel)
|
||||
// 我的渠道:部门可见或已授权。
|
||||
h.mux.HandleFunc("GET /api/v1/portal/channels", h.myChannels)
|
||||
// 数字员工:会话入口 + 调用记录。
|
||||
h.mux.HandleFunc("GET /api/v1/portal/digital-employees", h.digitalEmployees)
|
||||
h.mux.HandleFunc("POST /api/v1/portal/digital-employees/{code}/chat", h.runDigitalEmployee)
|
||||
@@ -925,3 +927,31 @@ func (h *HTTPHandler) myEmployeeRuns(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
apiresponse.OK(w, items)
|
||||
}
|
||||
|
||||
// --- 我的渠道:部门可见或已授权 ---
|
||||
|
||||
func (h *HTTPHandler) myChannels(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := h.account(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if h.service.channels == nil {
|
||||
apiresponse.OK(w, map[string]any{"channels": []any{}, "granted_codes": []string{}})
|
||||
return
|
||||
}
|
||||
visible, err := h.service.channels.VisibleChannelsForUser(r.Context(), a.ID, a.DepartmentID)
|
||||
if err != nil {
|
||||
portalError(w, err)
|
||||
return
|
||||
}
|
||||
granted, err := h.service.channels.GrantsForUser(r.Context(), a.ID)
|
||||
if err != nil {
|
||||
portalError(w, err)
|
||||
return
|
||||
}
|
||||
grantedCodes := make([]string, 0, len(granted))
|
||||
for _, item := range granted {
|
||||
grantedCodes = append(grantedCodes, item.Code)
|
||||
}
|
||||
apiresponse.OK(w, map[string]any{"channels": visible, "granted_codes": grantedCodes})
|
||||
}
|
||||
|
||||
@@ -180,10 +180,26 @@ func (s *Service) DecideResourceRequest(ctx context.Context, id, status, note, a
|
||||
if err = tx.QueryRow(ctx, `SELECT portal_user_id::text,resource_type,resource_code FROM gateway.resource_access_requests WHERE id=$1`, id).Scan(&userID, &resourceType, &resourceCode); err != nil {
|
||||
return ResourceRequest{}, err
|
||||
}
|
||||
if status == "approved" && resourceType != "channel" && s.market != nil {
|
||||
// 自动安装到申请用户工作区(use 等级)。
|
||||
if _, err = s.market.Install(ctx, resourceType, resourceCode, userID, "use"); err != nil {
|
||||
return ResourceRequest{}, err
|
||||
if status == "approved" {
|
||||
switch resourceType {
|
||||
case "mcp_server", "skill", "digital_employee":
|
||||
if s.market != nil {
|
||||
// 自动安装到申请用户工作区(use 等级)。
|
||||
if _, err = s.market.Install(ctx, resourceType, resourceCode, userID, "use"); err != nil {
|
||||
return ResourceRequest{}, err
|
||||
}
|
||||
}
|
||||
case "channel":
|
||||
// 渠道审批通过 = 写入 channel_grants 用户级授权。
|
||||
if s.channels != nil {
|
||||
var channelID string
|
||||
if err = tx.QueryRow(ctx, `SELECT id::text FROM gateway.channels WHERE code=$1`, resourceCode).Scan(&channelID); err != nil {
|
||||
return ResourceRequest{}, err
|
||||
}
|
||||
if err = s.channels.Grant(ctx, channelID, userID, actorID, "approval"); err != nil {
|
||||
return ResourceRequest{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{"request_id": id, "portal_user_id": userID, "resource_type": resourceType, "resource_code": resourceCode, "status": status, "actor_id": actorID})
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"aigateway.local/core/internal/channel"
|
||||
"aigateway.local/core/internal/identity"
|
||||
platformid "aigateway.local/core/internal/platform/id"
|
||||
"aigateway.local/core/internal/workbench"
|
||||
@@ -28,6 +29,7 @@ type Service struct {
|
||||
runtime http.Handler
|
||||
gateway http.Handler
|
||||
market *workbench.MarketplaceService
|
||||
channels *channel.Service
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, assets *workbench.Service, tools *workbench.ToolService, identityService *identity.Service) *Service {
|
||||
@@ -46,6 +48,10 @@ func (s *Service) SetApplicationRuntime(credentials *RuntimeCredentials, runtime
|
||||
// /v1/chat/completions with the user's own runtime credential.
|
||||
func (s *Service) SetGateway(gateway http.Handler) { s.gateway = gateway }
|
||||
|
||||
// SetChannelService wires the channel service for approval auto-grant and the
|
||||
// portal "my channels" visibility endpoint.
|
||||
func (s *Service) SetChannelService(service *channel.Service) { s.channels = service }
|
||||
|
||||
// SetMarketplace wires the resource-marketplace service into the portal so the
|
||||
// marketplace pages can browse, install and manage resources.
|
||||
func (s *Service) SetMarketplace(market *workbench.MarketplaceService) {
|
||||
|
||||
Reference in New Issue
Block a user