feat: manage existing credential actions with audited replay
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user