feat: manage existing credential actions with audited replay

This commit is contained in:
2026-11-01 19:10:37 +08:00
parent baa81c4923
commit 98ad60c0b8
18 changed files with 1350 additions and 53 deletions

View File

@@ -6,6 +6,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"unicode/utf8"
@@ -17,12 +18,14 @@ import (
)
const (
credentialOperationVersion = 2
credentialRotateAction = "ROTATE"
credentialRevokeAction = "REVOKE"
credentialExpiryAction = "SET_EXPIRY"
credentialFinalizeAction = "FINALIZE"
credentialOperationName = "credential.change.v1"
credentialOperationVersion = 2
credentialPermissionsVersion = 3
credentialRotateAction = "ROTATE"
credentialRevokeAction = "REVOKE"
credentialExpiryAction = "SET_EXPIRY"
credentialFinalizeAction = "FINALIZE"
credentialPermissionsAction = "SET_PERMISSIONS"
credentialOperationName = "credential.change.v1"
)
// CredentialStatus 是管理读取的脱敏凭据状态;永远不包含秘密或摘要。
@@ -46,6 +49,8 @@ type CredentialStatus struct {
NamespaceEnabled bool `json:"namespace_enabled"`
// ApplicationEnabled 所属应用是否启用。
ApplicationEnabled bool `json:"application_enabled"`
// Permissions 当前精确授权集合;队列范围只读,不允许凭据变更扩大或替换。
Permissions auth.Permissions `json:"permissions"`
}
// CredentialChangeInput 描述一次待固定的管理变更。
@@ -57,7 +62,7 @@ type CredentialChangeInput struct {
CredentialID core.ID
// ExpectedRevision 准备操作时观察到的版本。
ExpectedRevision uint64
// Action 为 ROTATE、REVOKE、SET_EXPIRY 或 FINALIZE。
// Action 为 ROTATE、REVOKE、SET_EXPIRY、FINALIZE 或 SET_PERMISSIONS。
Action string
// GraceFor 轮换时旧令牌的共存时长;零为立即失效,最多 30 天。
GraceFor time.Duration
@@ -65,6 +70,8 @@ type CredentialChangeInput struct {
ValidFor time.Duration
// Reason 必填审计原因。
Reason string
// Actions 仅 SET_PERMISSIONS 使用的完整非空动作集合,替换旧集合但保留队列范围。
Actions []string
}
// CredentialOperation 是可安全重放的私有操作文件内容。
@@ -90,6 +97,8 @@ type CredentialOperation struct {
PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"`
// ExpiresAt 轮换/调整有效期后的固定绝对时间;v2 为空表示长期,重放不能重新计算。
ExpiresAt *time.Time `json:"expires_at,omitempty"`
// Actions v3 SET_PERMISSIONS 的排序完整动作集合;省略保证旧 v1/v2 摘要不变。
Actions []string `json:"actions,omitempty"`
}
// CredentialChangeReceipt 是服务端保存并可重复返回的脱敏结果。
@@ -127,18 +136,25 @@ func GetCredentialStatus(ctx context.Context, db *sql.DB, namespace, credential
return out, err
}
var expires, revoked, previousUntil sql.NullTime
err := db.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,c.previous_valid_until,n.enabled,a.enabled
var permissions []byte
err := db.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,c.previous_valid_until,n.enabled,a.enabled,c.permissions
FROM orbit_credentials c
JOIN orbit_namespaces n ON n.id=c.namespace_id
JOIN orbit_applications a ON a.id=n.application_id
WHERE c.namespace_id=? AND c.id=?`, namespace, credential).
Scan(&out.Name, &out.Revision, &expires, &revoked, &previousUntil, &out.NamespaceEnabled, &out.ApplicationEnabled)
Scan(&out.Name, &out.Revision, &expires, &revoked, &previousUntil, &out.NamespaceEnabled, &out.ApplicationEnabled, &permissions)
if err == sql.ErrNoRows {
return out, fault.NotFound
}
if err != nil {
return out, err
}
if err := json.Unmarshal(permissions, &out.Permissions); err != nil {
return out, fmt.Errorf("stored credential permissions are invalid: %w", err)
}
if err := out.Permissions.Validate(); err != nil {
return out, err
}
out.NamespaceID, out.CredentialID = namespace, credential
out.ExpiresAt = nullableTime(expires)
out.PreviousValidUntil = nullableTime(previousUntil)
@@ -182,6 +198,11 @@ func PrepareCredentialOperation(ctx context.Context, db *sql.DB, in CredentialCh
out.ExpiresAt = timePtr(now.Add(in.ValidFor))
}
out.Version = credentialOperationVersion
if in.Action == credentialPermissionsAction {
out.Version = credentialPermissionsVersion
out.Actions = append([]string(nil), in.Actions...)
sort.Strings(out.Actions)
}
out.NamespaceID = in.NamespaceID
out.CredentialID = in.CredentialID
out.ExpectedRevision = in.ExpectedRevision
@@ -197,7 +218,7 @@ func PrepareCredentialOperation(ctx context.Context, db *sql.DB, in CredentialCh
// ValidateCredentialOperation validates the exact private operation shape.
// It is exported so the admin CLI can reject tampered files before opening a DB.
func ValidateCredentialOperation(op CredentialOperation) error {
if (op.Version != 1 && op.Version != credentialOperationVersion) || op.OperationID.Validate() != nil || op.NamespaceID.Validate() != nil || op.CredentialID.Validate() != nil {
if (op.Version != 1 && op.Version != credentialOperationVersion && op.Version != credentialPermissionsVersion) || op.OperationID.Validate() != nil || op.NamespaceID.Validate() != nil || op.CredentialID.Validate() != nil {
return fault.Invalid("credential operation identity or version is invalid")
}
if op.ExpectedRevision == 0 || !validCredentialAction(op.Action) {
@@ -206,6 +227,16 @@ func ValidateCredentialOperation(op CredentialOperation) error {
if err := validCredentialReason(op.Reason); err != nil {
return err
}
if op.Action == credentialPermissionsAction {
if op.Version != credentialPermissionsVersion || !sort.StringsAreSorted(op.Actions) {
return fault.Invalid("permission operation requires v3 and sorted actions")
}
if err := validateCredentialActions(op.Actions); err != nil {
return err
}
} else if op.Version == credentialPermissionsVersion || op.Actions != nil {
return fault.Invalid("actions are only valid for v3 SET_PERMISSIONS")
}
if op.Version == 1 && ((op.Action != credentialRotateAction && op.Action != credentialRevokeAction) || op.PreviousValidUntil != nil || (op.Action == credentialRotateAction && op.ExpiresAt == nil)) {
return fault.Invalid("legacy operation requires its original expiry and immediate rotation")
}
@@ -255,13 +286,14 @@ func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper
var name string
var revision uint64
var expires, revoked, previousUntil sql.NullTime
var storedPermissions []byte
var namespaceEnabled, applicationEnabled bool
err := tx.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,c.previous_valid_until,n.enabled,a.enabled
err := tx.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,c.previous_valid_until,n.enabled,a.enabled,c.permissions
FROM orbit_credentials c
JOIN orbit_namespaces n ON n.id=c.namespace_id
JOIN orbit_applications a ON a.id=n.application_id
WHERE c.namespace_id=? AND c.id=? FOR UPDATE`, op.NamespaceID, op.CredentialID).
Scan(&name, &revision, &expires, &revoked, &previousUntil, &namespaceEnabled, &applicationEnabled)
Scan(&name, &revision, &expires, &revoked, &previousUntil, &namespaceEnabled, &applicationEnabled, &storedPermissions)
if err == sql.ErrNoRows {
return fault.NotFound
}
@@ -297,6 +329,24 @@ func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper
out.ExpiresAt = nullableTime(expires)
out.PreviousValidUntil = nullableTime(previousUntil)
switch op.Action {
case credentialPermissionsAction:
var previous auth.Permissions
if err := json.Unmarshal(storedPermissions, &previous); err != nil {
return fmt.Errorf("stored credential permissions are invalid: %w", err)
}
if err := previous.Validate(); err != nil {
return err
}
current := auth.Permissions{Actions: op.Actions, Queues: previous.Queues}
before["permissions"], after["permissions"] = previous, current
actions, err := json.Marshal(op.Actions)
if err != nil {
return err
}
// 只替换动作路径;队列 JSON、两代摘要、密文、有效期和过渡截止均原样保留。
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET permissions=JSON_SET(permissions,'$.actions',CAST(? AS JSON)),revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, actions, now, op.NamespaceID, op.CredentialID, revision); err != nil {
return err
}
case credentialRotateAction:
parsed, _ := auth.Parse(op.Token)
// 仍在过渡期时不覆盖上一代,避免把尚未切换的服务突然踢下线。
@@ -486,12 +536,33 @@ func validateCredentialChangeInput(in CredentialChangeInput) error {
if in.ValidFor != 0 && (in.ValidFor < time.Hour || in.ValidFor > 365*24*time.Hour) {
return fault.Invalid("validity must be zero (permanent) or between one hour and one year")
}
if (in.Action == credentialRevokeAction || in.Action == credentialFinalizeAction) && in.ValidFor != 0 {
if (in.Action == credentialRevokeAction || in.Action == credentialFinalizeAction || in.Action == credentialPermissionsAction) && in.ValidFor != 0 {
return fault.Invalid("this operation cannot set validity")
}
if in.GraceFor < 0 || in.GraceFor > 30*24*time.Hour || (in.Action != credentialRotateAction && in.GraceFor != 0) {
return fault.Invalid("rotation grace must be between zero and 30 days")
}
if in.Action == credentialPermissionsAction {
return validateCredentialActions(in.Actions)
}
if in.Actions != nil {
return fault.Invalid("actions are only valid for SET_PERMISSIONS")
}
return nil
}
// validateCredentialActions 复用认证动作白名单,并拒绝重复项,避免全量替换的歧义。
func validateCredentialActions(actions []string) error {
if err := (auth.Permissions{Actions: actions, Queues: []string{"*"}}).Validate(); err != nil {
return err
}
seen := make(map[string]bool, len(actions))
for _, action := range actions {
if seen[action] {
return fault.Invalid("duplicate permission action")
}
seen[action] = true
}
return nil
}
@@ -516,5 +587,5 @@ func timePtr(v time.Time) *time.Time {
}
func validCredentialAction(action string) bool {
return action == credentialRotateAction || action == credentialRevokeAction || action == credentialExpiryAction || action == credentialFinalizeAction
return action == credentialRotateAction || action == credentialRevokeAction || action == credentialExpiryAction || action == credentialFinalizeAction || action == credentialPermissionsAction
}