完善角色日志隔离与插件权限校验
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-10 10:40:14 +08:00
parent ee38207c6f
commit 46fd3a6bff
8 changed files with 208 additions and 27 deletions

View File

@ -19,7 +19,7 @@ const (
)
func GetPluginList(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
if err := authorizeCapability(ctx, access.CapabilityConfigRead, "", false); err != nil {
if err := authorizeCapability(ctx, access.CapabilityPluginRead, "", false); err != nil {
response.NewErrorResponse(response.NoAccessPrivileges, "\u65e0\u6743\u8bbf\u95ee", "").FPrint(w)
return
}
@ -32,7 +32,7 @@ func GetPluginList(ctx *context.Context, w http.ResponseWriter, req *http.Reques
}
func SettingsHtml(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
if err := authorizeCapability(ctx, access.CapabilityConfigRead, "", false); err != nil {
if err := authorizeCapability(ctx, access.CapabilityPluginReadGlobal, "", false); err != nil {
response.NewErrorResponse(response.NoAccessPrivileges, "\u65e0\u6743\u8bbf\u95ee", "").FPrint(w)
return
}

View File

@ -24,7 +24,7 @@ type telegramSettingsRequest struct {
}
func GetTelegramUserSettings(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
if !authenticatedPluginUser(ctx) {
if !authorizedPluginUser(ctx) {
response.NewErrorResponse(response.NeedLogin, "请先登录", "").FPrint(w)
return
}
@ -42,7 +42,7 @@ func GetTelegramUserSettings(ctx *context.Context, w http.ResponseWriter, req *h
}
func UpdateTelegramUserSettings(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
if !authenticatedPluginUser(ctx) {
if !authorizedPluginUser(ctx) {
response.NewErrorResponse(response.NeedLogin, "请先登录", "").FPrint(w)
return
}
@ -71,7 +71,7 @@ func UpdateTelegramUserSettings(ctx *context.Context, w http.ResponseWriter, req
}
func GetTelegramBotInfo(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
if !authenticatedPluginUser(ctx) {
if !authorizedPluginUser(ctx) {
response.NewErrorResponse(response.NeedLogin, "请先登录", "").FPrint(w)
return
}
@ -138,6 +138,10 @@ func authenticatedPluginUser(ctx *context.Context) bool {
return ctx != nil && ctx.UserID > 0
}
func authorizedPluginUser(ctx *context.Context) bool {
return authenticatedPluginUser(ctx) && authorizeCapability(ctx, access.CapabilityPluginRead, "", true) == nil
}
func decodePluginJSONBody(w http.ResponseWriter, req *http.Request, target any) bool {
req.Body = http.MaxBytesReader(w, req.Body, maxTelegramUserSettingsBodyBytes)
defer req.Body.Close()

View File

@ -0,0 +1,86 @@
package migration
import (
"context"
"encoding/json"
"fmt"
"strings"
"git.pcux.cn/xuans/Smail/services/access"
)
type operationLogActorRole struct {
ID string
Role string
}
// applyOperationLogActorRoleSchema 将操作发生时的角色从历史载荷回填到独立字段。
func applyOperationLogActorRoleSchema(ctx context.Context, exec executor, dialect Dialect) error {
definitions := map[Dialect]string{
DialectSQLite: `TEXT NULL`,
DialectMySQL: `VARCHAR(20) NULL`,
DialectPostgres: `VARCHAR(20) NULL`,
}
definition, ok := definitions[dialect]
if !ok {
return fmt.Errorf("unsupported migration dialect %q", dialect)
}
if err := ensureColumn(ctx, exec, dialect, "operation_log", "actor_role", definition); err != nil {
return err
}
rows, err := exec.QueryContext(ctx, "SELECT id, payload FROM operation_log")
if err != nil {
return fmt.Errorf("query operation log history: %w", err)
}
items := make([]operationLogActorRole, 0)
for rows.Next() {
var item operationLogActorRole
var payload string
if err := rows.Scan(&item.ID, &payload); err != nil {
_ = rows.Close()
return fmt.Errorf("scan operation log history: %w", err)
}
item.Role = historicalActorRole(payload)
if item.Role != "" {
items = append(items, item)
}
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return fmt.Errorf("iterate operation log history: %w", err)
}
if err := rows.Close(); err != nil {
return fmt.Errorf("close operation log history: %w", err)
}
update := `UPDATE "operation_log" SET "actor_role" = ? WHERE "id" = ? AND "actor_role" IS NULL`
if dialect == DialectMySQL {
update = "UPDATE `operation_log` SET `actor_role` = ? WHERE `id` = ? AND `actor_role` IS NULL"
} else if dialect == DialectPostgres {
update = `UPDATE "operation_log" SET "actor_role" = $1 WHERE "id" = $2 AND "actor_role" IS NULL`
}
for _, item := range items {
if err := ctx.Err(); err != nil {
return err
}
if _, err := exec.ExecContext(ctx, update, item.Role, item.ID); err != nil {
return fmt.Errorf("backfill operation log actor role: %w", err)
}
}
return nil
}
func historicalActorRole(payload string) string {
var event struct {
ActorRole string `json:"actorRole"`
}
if json.Unmarshal([]byte(payload), &event) != nil {
return ""
}
role := access.Role(strings.TrimSpace(event.ActorRole))
if !role.Valid() {
return ""
}
return string(role)
}

View File

@ -27,6 +27,7 @@ func DefaultMigrations() []Migration {
DefineCustom(15, "expand_security_setup", "expand_security_setup_v1", applySecuritySetupSchema),
DefineCustom(16, "expand_user_security_preferences", "expand_user_security_preferences_v1", applyUserSecurityPreferencesSchema),
DefineForDialects(17, "create_managed_logs", logStatements()),
DefineCustom(18, "expand_operation_log_actor_role", "expand_operation_log_actor_role_v1", applyOperationLogActorRoleSchema),
}
}

View File

@ -8,6 +8,7 @@ import (
"git.pcux.cn/xuans/Smail/hooks/framework"
"git.pcux.cn/xuans/Smail/hooks/spam_block/tools"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/utils/context"
"git.pcux.cn/xuans/Smail/utils/credential"
log "github.com/sirupsen/logrus"
@ -73,10 +74,7 @@ func (s *SpamBlock) Configure(data json.RawMessage) error {
if err != nil {
return err
}
s.mu.Lock()
s.cfg = cfg
s.hc = newHTTPClientForEndpoint(cfg.ApiTimeout, cfg.ApiURL)
s.mu.Unlock()
s.applyConfig(cfg)
return nil
}
@ -99,13 +97,17 @@ var jquery string
// SettingsHtml 插件页面
func (s *SpamBlock) SettingsHtml(ctx *context.Context, url string, requestData string) string {
parts := strings.Split(strings.Trim(url, "/"), "/")
if len(parts) > 0 && parts[len(parts)-1] == "save" {
return "旧版插件配置保存接口已停用,请使用新版插件配置接口"
}
if strings.Contains(url, "jquery.js") {
return jquery
}
if strings.Contains(url, "index.html") {
if !ctx.IsAdmin {
if !canManageSpamBlockSettings(ctx) {
return fmt.Sprintf(`
<div>
请联系管理员进行配置。
@ -117,9 +119,10 @@ func (s *SpamBlock) SettingsHtml(ctx *context.Context, url string, requestData s
s.mu.RUnlock()
return fmt.Sprintf(index, template.HTMLEscapeString(cfg.ApiURL), cfg.ApiTimeout, cfg.Threshold)
}
if !ctx.IsAdmin {
if !canManageSpamBlockSettings(ctx) {
return "没有访问权限"
}
return "没有访问权限"
cfg, err := decodeSpamBlockConfig([]byte(requestData))
if err != nil {
@ -132,6 +135,14 @@ func (s *SpamBlock) SettingsHtml(ctx *context.Context, url string, requestData s
}
func canManageSpamBlockSettings(ctx *context.Context) bool {
if ctx == nil {
return false
}
role := ctx.EffectiveRole()
return role == access.RoleSuperAdmin || role == access.RoleAdmin
}
type ModelResponse struct {
Predictions [][]float64 `json:"predictions"`
}

View File

@ -136,7 +136,7 @@ func operationLog(ctx *appcontext.Context, req *http.Request, w http.ResponseWri
}{ctx.UserAccount, string(ctx.EffectiveRole()), observed.code,
operationObject(path), req.Method + " " + path, operationOutcome(observed.code, result), observed.status})
logstore.Record(context.Background(), logstore.Entry{
Kind: "operation", CreatedAt: time.Now(), ActorID: strconv.Itoa(ctx.UserID), Action: action, Result: result, Payload: payload,
Kind: "operation", CreatedAt: time.Now(), ActorID: strconv.Itoa(ctx.UserID), ActorRole: string(ctx.EffectiveRole()), Action: action, Result: result, Payload: payload,
})
}
}

View File

@ -17,7 +17,7 @@ var managementEndpoints = []Endpoint{
{Method: "POST", Path: "/api/user/info", Capability: CapabilityUserRead},
{Method: "POST", Path: "/api/user/list", Capability: CapabilityUserRead},
{Method: "GET", Path: "/api/plugin/list", Capability: CapabilityPluginRead},
{Method: "*", Path: "/api/plugin/settings/", Capability: CapabilityPluginWriteOwn},
{Method: "*", Path: "/api/plugin/settings/", Capability: CapabilityPluginReadGlobal},
{Method: "GET", Path: "/api/v1/config/status", Capability: CapabilityConfigRead},
{Method: "GET", Path: "/api/v1/config/backups", Capability: CapabilityConfigRollback},
{Method: "POST", Path: "/api/v1/config/rollback/preview", Capability: CapabilityConfigRollback},
@ -45,7 +45,7 @@ var managementEndpoints = []Endpoint{
{Method: "POST", Path: "/api/v1/user/info", Capability: CapabilityUserRead},
{Method: "POST", Path: "/api/v1/user/list", Capability: CapabilityUserRead},
{Method: "GET", Path: "/api/v1/plugin/list", Capability: CapabilityPluginRead},
{Method: "*", Path: "/api/v1/plugin/settings/", Capability: CapabilityPluginWriteOwn},
{Method: "*", Path: "/api/v1/plugin/settings/", Capability: CapabilityPluginReadGlobal},
}
func ManagementEndpoints() []Endpoint {

View File

@ -9,11 +9,13 @@ import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"git.pcux.cn/xuans/Smail/db/migration"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/utils/logpolicy"
)
@ -25,6 +27,7 @@ type Entry struct {
CreatedAt time.Time `json:"createdAt"`
Kind string `json:"kind"`
ActorID string `json:"actorId"`
ActorRole string `json:"actorRole,omitempty"`
Action string `json:"action"`
Section string `json:"section"`
Result string `json:"result"`
@ -116,6 +119,20 @@ func (s *Store) Write(ctx context.Context, entry Entry) error {
if emptyEntry(entry) {
return nil
}
if isOperationKind(entry.Kind) {
role, present := actorRoleFromPayload(entry.Payload)
if entry.ActorRole != "" {
role = strings.TrimSpace(entry.ActorRole)
present = true
}
if present && !access.Role(role).Valid() {
return ErrInvalid
}
if !present {
role = s.lookupActorRole(ctx, entry.ActorID)
}
entry.ActorRole = role
}
if entry.ID == "" {
var token [16]byte
if _, err := rand.Read(token[:]); err != nil {
@ -123,14 +140,22 @@ func (s *Store) Write(ctx context.Context, entry Entry) error {
}
entry.ID = "log-" + hex.EncodeToString(token[:])
}
statement := "INSERT INTO " + table + " (id, created_at, kind, actor_id, action, section, result, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
columns := "id, created_at, kind, actor_id, action, section, result, payload"
values := "?, ?, ?, ?, ?, ?, ?, ?"
args := []any{entry.ID, entry.CreatedAt.UTC().UnixNano(), entry.Kind, entry.ActorID, entry.Action, entry.Section, entry.Result, string(entry.Payload)}
if isOperationKind(entry.Kind) {
columns = "id, created_at, kind, actor_id, actor_role, action, section, result, payload"
values = "?, ?, ?, ?, ?, ?, ?, ?, ?"
args = []any{entry.ID, entry.CreatedAt.UTC().UnixNano(), entry.Kind, entry.ActorID, nullableString(entry.ActorRole), entry.Action, entry.Section, entry.Result, string(entry.Payload)}
}
statement := "INSERT INTO " + table + " (" + columns + ") VALUES (" + values + ")"
// 仅忽略同一稳定标识的重放,不用 INSERT IGNORE 掩盖截断、缺字段等错误。
if s.dialect == migration.DialectMySQL {
statement += " ON DUPLICATE KEY UPDATE id = id"
} else {
statement += " ON CONFLICT (id) DO NOTHING"
}
_, err = s.db.ExecContext(ctx, s.bind(statement), entry.ID, entry.CreatedAt.UTC().UnixNano(), entry.Kind, entry.ActorID, entry.Action, entry.Section, entry.Result, string(entry.Payload))
_, err = s.db.ExecContext(ctx, s.bind(statement), args...)
if err != nil {
return ErrUnavailable
}
@ -149,7 +174,11 @@ func (s *Store) Query(ctx context.Context, query Query) ([]Entry, error) {
if query.From.After(from) {
from = query.From
}
statement := "SELECT id, created_at, kind, actor_id, action, section, result, payload FROM " + table + " WHERE created_at >= ? AND kind = ?"
columns := "id, created_at, kind, actor_id, action, section, result, payload"
if isOperationKind(query.Kind) {
columns = "id, created_at, kind, actor_id, actor_role, action, section, result, payload"
}
statement := "SELECT " + columns + " FROM " + table + " WHERE created_at >= ? AND kind = ?"
args := []any{from.UnixNano(), query.Kind}
if query.ScopeActorID != "" {
statement += " AND (actor_id = ?"
@ -163,15 +192,8 @@ func (s *Store) Query(ctx context.Context, query Query) ([]Entry, error) {
statement += "actor_id IN (SELECT CAST(id AS " + idType + ") FROM " + userTable + " WHERE " + models.OrdinaryUserPredicate + ")"
// 代管视图还要核对操作发生时的角色,不能因账号降级暴露旧管理操作。
// 缺少历史角色的操作仅本人和超级管理员可见,收发信元数据不包含管理操作。
if query.Kind == "operation" {
switch s.dialect {
case migration.DialectMySQL:
statement += " AND CASE WHEN JSON_VALID(payload) THEN JSON_UNQUOTE(JSON_EXTRACT(payload, '$.actorRole')) ELSE NULL END = 'user'"
case migration.DialectPostgres:
statement += " AND (payload::jsonb ->> 'actorRole') = 'user'"
default:
statement += " AND CASE WHEN json_valid(payload) THEN json_extract(payload, '$.actorRole') ELSE NULL END = 'user'"
}
if isOperationKind(query.Kind) {
statement += " AND actor_role = 'user'"
}
statement += ")"
}
@ -205,10 +227,19 @@ func (s *Store) Query(ctx context.Context, query Query) ([]Entry, error) {
var entry Entry
var timestamp int64
var payload string
if err := rows.Scan(&entry.ID, &timestamp, &entry.Kind, &entry.ActorID, &entry.Action, &entry.Section, &entry.Result, &payload); err != nil {
var actorRole sql.NullString
scanArgs := []any{&entry.ID, &timestamp, &entry.Kind, &entry.ActorID}
if isOperationKind(query.Kind) {
scanArgs = append(scanArgs, &actorRole)
}
scanArgs = append(scanArgs, &entry.Action, &entry.Section, &entry.Result, &payload)
if err := rows.Scan(scanArgs...); err != nil {
return nil, ErrUnavailable
}
entry.CreatedAt = time.Unix(0, timestamp).UTC()
if actorRole.Valid {
entry.ActorRole = actorRole.String
}
entry.Payload = json.RawMessage(payload)
items = append(items, entry)
}
@ -218,6 +249,54 @@ func (s *Store) Query(ctx context.Context, query Query) ([]Entry, error) {
return items, nil
}
func isOperationKind(kind string) bool {
return kind == "operation" || kind == "config"
}
func actorRoleFromPayload(payload []byte) (string, bool) {
var event struct {
ActorRole *string `json:"actorRole"`
}
if json.Unmarshal(payload, &event) != nil || event.ActorRole == nil {
return "", false
}
return strings.TrimSpace(*event.ActorRole), true
}
func nullableString(value string) any {
if strings.TrimSpace(value) == "" {
return nil
}
return value
}
func (s *Store) lookupActorRole(ctx context.Context, actorID string) string {
id, err := strconv.Atoi(strings.TrimSpace(actorID))
if err != nil || id <= 0 {
return ""
}
if ctx == nil {
ctx = context.Background()
}
userTable := `"user"`
if s.dialect == migration.DialectMySQL {
userTable = "`user`"
}
var role sql.NullString
var isAdmin int
err = s.db.QueryRowContext(ctx, s.bind("SELECT role, is_admin FROM "+userTable+" WHERE id = ?"), id).Scan(&role, &isAdmin)
if err != nil {
return ""
}
if access.Role(strings.TrimSpace(role.String)).Valid() {
return strings.TrimSpace(role.String)
}
if isAdmin == 1 {
return string(access.RoleSuperAdmin)
}
return string(access.RoleUser)
}
// Cleanup 每批先按索引选取标识再删除,避免 MySQL 专属 DELETE LIMIT 和大事务锁表。
func (s *Store) Cleanup(ctx context.Context, now time.Time) (int64, error) {
if s == nil || s.db == nil {