Files
orbit/internal/storage/mysql/retention_policy.go

162 lines
6.4 KiB
Go

package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/internal/retention"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
driver "github.com/go-sql-driver/mysql"
)
const retentionPolicyOperation = "retention.policy.update"
const retentionPolicyColumns = `revision,enabled,idempotency_days,task_history_days,workflow_history_days,event_log_days,delivery_history_days,inbox_history_days,audit_log_days,updated_at`
// RetentionPolicyStore 保存命名空间级策略,数据库配置优先于部署默认值。
// 不缓存配置;节点重启和跨节点读取保持数据库中同一版本。
type RetentionPolicyStore struct {
db *sql.DB
defaults retention.Policy
}
func NewRetentionPolicyStore(db *sql.DB, defaults ...retention.Policy) *RetentionPolicyStore {
p := retention.Defaults("")
if len(defaults) > 0 {
p = defaults[0]
}
return &RetentionPolicyStore{db: db, defaults: p}
}
var _ retention.Store = (*RetentionPolicyStore)(nil)
func (s *RetentionPolicyStore) defaultPolicy(namespace core.ID) retention.Policy {
p := s.defaults
p.NamespaceID = namespace
p.Source = "default"
return p
}
type retentionQuery interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}
func (s *RetentionPolicyStore) readPolicy(ctx context.Context, db retentionQuery, namespace core.ID, lock bool) (retention.Policy, error) {
p := s.defaultPolicy(namespace)
q := `SELECT ` + retentionPolicyColumns + ` FROM orbit_retention_policies WHERE namespace_id=?`
if lock {
q += " FOR UPDATE"
}
err := db.QueryRowContext(ctx, q, namespace).Scan(&p.Revision, &p.Enabled, &p.IdempotencyDays, &p.TaskHistoryDays, &p.WorkflowHistoryDays, &p.EventLogDays, &p.DeliveryHistoryDays, &p.InboxHistoryDays, &p.AuditLogDays, &p.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return p, p.Validate()
}
if err != nil {
return retention.Policy{}, err
}
p.Source = "database"
return p, p.Validate()
}
// GetRetentionPolicy 供经过会话认证的控制台和数据库管理工具读取配置。
func (s *RetentionPolicyStore) GetRetentionPolicy(ctx context.Context, namespace core.ID) (retention.Policy, error) {
if namespace.Validate() != nil {
return retention.Policy{}, fault.Invalid("invalid namespace")
}
return s.readPolicy(ctx, s.db, namespace, false)
}
// retentionActor 在策略事务内验证控制台会话。普通 SDK 凭据不能修改保留策略。
// 普通会话要求全部队列管理范围;部署指定管理员的显式范围在同一事务重新核验。
func retentionActor(ctx context.Context, tx *sql.Tx, token auth.Token) (core.ID, error) {
if token.Console != nil {
return consoleScopeActor(ctx, tx, token)
}
p, err := authenticate(ctx, tx, token)
if err != nil {
return "", err
}
all := false
for _, q := range p.Queues {
if q == "*" {
all = true
}
}
if !all || !p.Allows("queues:manage") {
return "", fault.Forbidden
}
var user core.ID
err = tx.QueryRowContext(ctx, `SELECT u.id FROM orbit_console_sessions s
JOIN orbit_console_users u ON u.id=s.user_id AND u.namespace_id=s.namespace_id AND u.credential_id=s.credential_id
WHERE s.namespace_id=? AND s.credential_id=? AND s.token_digest=? AND s.revoked_at IS NULL
AND s.expires_at>UTC_TIMESTAMP(6) AND u.enabled=TRUE FOR SHARE`, token.NamespaceID, token.CredentialID, token.Digest).Scan(&user)
if errors.Is(err, sql.ErrNoRows) {
return "", fault.Unauthorized
}
return user, err
}
// UpdateRetentionPolicy 在同一事务中核验版本、保存策略、前后快照、账号身份和幂等回执。
func (s *RetentionPolicyStore) UpdateRetentionPolicy(ctx context.Context, token auth.Token, key string, in retention.UpdateInput) (retention.Policy, error) {
if strings.TrimSpace(key) == "" || len(key) > 128 {
return retention.Policy{}, fault.Invalid("idempotency key must be 1-128 bytes")
}
want, err := in.ToPolicy(token.NamespaceID)
if err != nil {
return retention.Policy{}, fault.Invalid(err.Error())
}
var out retention.Policy
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
user, err := retentionActor(ctx, tx, token)
if err != nil {
return err
}
identity, cached, err := reserveIdentity(ctx, tx, token.NamespaceID, retentionPolicyOperation, "", key, "retention_policy", token.NamespaceID, in)
if err != nil {
return err
}
if cached != nil {
return json.Unmarshal(cached, &out)
}
current, err := s.readPolicy(ctx, tx, token.NamespaceID, true)
if err != nil {
return err
}
if current.Revision != in.ExpectedRevision {
return fault.Conflict
}
if current.Revision == 0 {
_, err = tx.ExecContext(ctx, `INSERT INTO orbit_retention_policies(namespace_id,enabled,idempotency_days,task_history_days,workflow_history_days,event_log_days,delivery_history_days,inbox_history_days,audit_log_days) VALUES (?,?,?,?,?,?,?,?,?)`, token.NamespaceID, want.Enabled, want.IdempotencyDays, want.TaskHistoryDays, want.WorkflowHistoryDays, want.EventLogDays, want.DeliveryHistoryDays, want.InboxHistoryDays, want.AuditLogDays)
var me *driver.MySQLError
if errors.As(err, &me) && me.Number == 1062 {
return fault.Conflict
}
} else {
_, err = tx.ExecContext(ctx, `UPDATE orbit_retention_policies SET revision=revision+1,enabled=?,idempotency_days=?,task_history_days=?,workflow_history_days=?,event_log_days=?,delivery_history_days=?,inbox_history_days=?,audit_log_days=?,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND revision=?`, want.Enabled, want.IdempotencyDays, want.TaskHistoryDays, want.WorkflowHistoryDays, want.EventLogDays, want.DeliveryHistoryDays, want.InboxHistoryDays, want.AuditLogDays, token.NamespaceID, in.ExpectedRevision)
}
if err != nil {
return err
}
out, err = s.readPolicy(ctx, tx, token.NamespaceID, false)
if err != nil {
return err
}
auditID, err := core.NewID()
if err != nil {
return err
}
old, _ := json.Marshal(current)
updated, _ := json.Marshal(out)
_, err = tx.ExecContext(ctx, "INSERT INTO orbit_audit_events(namespace_id,id,operation_id,actor_id,action,resource_kind,resource_id,reason,`before`,`after`) VALUES (?,?,?,?,?,'retention_policy',?,?,?,?)", token.NamespaceID, auditID, identity, "console:"+string(user), retentionPolicyOperation, token.NamespaceID, in.Reason, string(old), string(updated))
if err != nil {
return err
}
return saveIdentity(ctx, tx, token.NamespaceID, identity, out)
})
return out, err
}