feat: support permanent credentials and overlapping token rotation

This commit is contained in:
2026-09-27 11:45:15 +08:00
parent 87aa710013
commit 385a618154
29 changed files with 718 additions and 161 deletions

View File

@@ -95,6 +95,7 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) {
// 新增的控制台和保留策略迁移也要还原到历史 v9,避免升级测试把
// 已经存在的 v29/v30 表误当成迁移重复执行。
f.exec(`DROP TABLE orbit_archive_jobs,orbit_archive_plans,orbit_retention_policies,orbit_console_sessions,orbit_console_users`)
f.exec(`ALTER TABLE orbit_credentials DROP CHECK ck_credential_overlap, DROP COLUMN previous_secret_digest, DROP COLUMN previous_valid_until`)
f.exec(`DELETE FROM orbit_schema_migrations WHERE version>=10`)
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)

View File

@@ -1,4 +1,4 @@
-- Orbit fresh-install baseline, generated from migration version 31.
-- Orbit fresh-install baseline, generated from migration version 32.
-- This file intentionally contains no database-level relation constraints.
CREATE TABLE `orbit_application_resources` (
`application_id` binary(16) NOT NULL COMMENT '所属应用标识及主键,跨命名空间共享额度',
@@ -59,12 +59,15 @@ CREATE TABLE `orbit_credentials` (
`updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
`name` varchar(128) COLLATE utf8mb4_0900_as_cs NOT NULL COMMENT '可读名称,唯一性范围由对应数据表约束',
`secret_digest` binary(32) NOT NULL COMMENT '凭据秘密的 SHA-256 摘要,不保存或输出原文',
`previous_secret_digest` binary(32) DEFAULT NULL COMMENT '上一代秘密的 SHA-256 摘要;为空表示无过渡令牌,不保存原文',
`previous_valid_until` datetime(6) DEFAULT NULL COMMENT '上一代令牌过渡截止,UTC;不晚于其原到期时间,与旧摘要同时为空或非空',
`permissions` json NOT NULL COMMENT '授权范围的 JSON 快照,必须由服务端解释和校验',
`expires_at` datetime(6) DEFAULT NULL COMMENT '凭据过期时间,统一 UTC;为空表示没有配置到期时间',
`revoked_at` datetime(6) DEFAULT NULL COMMENT '撤销时间;为空表示尚未撤销',
PRIMARY KEY (`namespace_id`,`id`),
UNIQUE KEY `uq_credential_name` (`namespace_id`,`name`),
CONSTRAINT `ck_orbit_credentials_0` CHECK ((`revision` > 0))
CONSTRAINT `ck_orbit_credentials_0` CHECK ((`revision` > 0)),
CONSTRAINT `ck_credential_overlap` CHECK ((previous_secret_digest IS NULL AND previous_valid_until IS NULL) OR (previous_secret_digest IS NOT NULL AND previous_valid_until IS NOT NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='命名空间凭据,只保存秘密摘要和授权范围';
CREATE TABLE `orbit_deliveries` (
`namespace_id` binary(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',

View File

@@ -16,39 +16,43 @@ import (
// BootstrapInput 仅供持有数据库管理权限的初始化命令使用,不暴露为公共 HTTP。
type BootstrapInput struct {
WorkflowAuditAccess bool // WorkflowAuditAccess 显式授予流程操作审计读取;不授予其他管理操作。
WorkflowRunAccess bool // WorkflowRunAccess 显式授予流程启动和读取,不包含发布或管理。
WorkflowControlAccess bool // WorkflowControlAccess 显式授予流程暂停、继续和取消及读取。
WorkflowSignalAccess bool // WorkflowSignalAccess 显式授予流程信号受理及读取。
WorkflowRecoveryAccess bool // WorkflowRecoveryAccess 显式授予有证据的流程人工恢复及读取。
WorkflowAccess bool // WorkflowAccess 显式授予流程定义读取和发布权限,不含实例执行权限。
RecoveryAccess bool // RecoveryAccess 显式授予任务核查、人工重试及操作审计读取权限。
EventAccess bool // EventAccess 显式增加结果订阅查询、管理和消费权限。
Application string // Application 新应用名称,与 ExistingApplicationID 二选一。
Namespace string // Namespace 命名空间名称。
Environment string // Environment 环境名称,必须显式指定。
Queue string // Queue 初始队列名称。
WorkerAccess bool // WorkerAccess 显式增加开发用 Worker 接入及消费权限。
ScheduleAccess bool // ScheduleAccess 显式增加开发用计划查询及管理权限。
ResourceAccess bool // ResourceAccess 显式授予整个应用的额度查询及管理权限。
ExistingApplicationID core.ID // ExistingApplicationID 在指定现有应用下创建命名空间,与 Application 二选一。
ConsoleUsername string // ConsoleUsername 可选的首个控制台管理员用户名;与 ConsolePassword 成对使用。
ConsoleDisplayName string // ConsoleDisplayName 可选的控制台展示名称,空时使用用户名。
ConsolePassword string // ConsolePassword 仅在初始化事务内短暂使用,数据库只保存 bcrypt 哈希。
ValidFor time.Duration // ValidFor 初始令牌有效时长;0 为长期,定期范围 1 小时至 365 天。
WorkflowAuditAccess bool // WorkflowAuditAccess 显式授予流程操作审计读取;不授予其他管理操作。
WorkflowRunAccess bool // WorkflowRunAccess 显式授予流程启动和读取,不包含发布或管理。
WorkflowControlAccess bool // WorkflowControlAccess 显式授予流程暂停、继续和取消及读取。
WorkflowSignalAccess bool // WorkflowSignalAccess 显式授予流程信号受理及读取。
WorkflowRecoveryAccess bool // WorkflowRecoveryAccess 显式授予有证据的流程人工恢复及读取。
WorkflowAccess bool // WorkflowAccess 显式授予流程定义读取和发布权限,不含实例执行权限。
RecoveryAccess bool // RecoveryAccess 显式授予任务核查、人工重试及操作审计读取权限。
EventAccess bool // EventAccess 显式增加结果订阅查询、管理和消费权限。
Application string // Application 新应用名称,与 ExistingApplicationID 二选一。
Namespace string // Namespace 命名空间名称。
Environment string // Environment 环境名称,必须显式指定。
Queue string // Queue 初始队列名称。
WorkerAccess bool // WorkerAccess 显式增加开发用 Worker 接入及消费权限。
ScheduleAccess bool // ScheduleAccess 显式增加开发用计划查询及管理权限。
ResourceAccess bool // ResourceAccess 显式授予整个应用的额度查询及管理权限。
ExistingApplicationID core.ID // ExistingApplicationID 在指定现有应用下创建命名空间,与 Application 二选一。
ConsoleUsername string // ConsoleUsername 可选的首个控制台管理员用户名;与 ConsolePassword 成对使用。
ConsoleDisplayName string // ConsoleDisplayName 可选的控制台展示名称,空时使用用户名。
ConsolePassword string // ConsolePassword 仅在初始化事务内短暂使用,数据库只保存 bcrypt 哈希。
}
// BootstrapResult 包含一次性交付的原始凭据,禁止记录到日志或提交到版本库。
type BootstrapResult struct {
ApplicationID core.ID `json:"application_id"` // ApplicationID 创建或关联的应用标识。
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 新建隔离范围。
QueueID core.ID `json:"queue_id"` // QueueID 初始队列标识。
CredentialID core.ID `json:"credential_id"` // CredentialID 凭据记录标识。
Token string `json:"token"` // Token 原始秘密,仅保存到受保护交付文件。
ExpiresAt time.Time `json:"expires_at"` // ExpiresAt 初始凭据到期时间,UTC。
ConsoleUsername string `json:"console_username,omitempty"` // ConsoleUsername 可选的首个控制台账号,不包含密码。
ApplicationID core.ID `json:"application_id"` // ApplicationID 创建或关联的应用标识。
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 新建隔离范围。
QueueID core.ID `json:"queue_id"` // QueueID 初始队列标识。
CredentialID core.ID `json:"credential_id"` // CredentialID 凭据记录标识。
Token string `json:"token"` // Token 原始秘密,仅保存到受保护交付文件。
ExpiresAt *time.Time `json:"expires_at,omitempty"` // ExpiresAt 初始凭据到期时间,UTC;空为长期。
ConsoleUsername string `json:"console_username,omitempty"` // ConsoleUsername 可选的首个控制台账号,不包含密码。
}
func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(BootstrapResult) error) error {
if in.ValidFor != 0 && (in.ValidFor < time.Hour || in.ValidFor > 365*24*time.Hour) {
return fmt.Errorf("validity must be zero or between one hour and one year")
}
for _, v := range []string{in.Namespace, in.Queue} {
if !service.ValidName(v, 128) {
return fmt.Errorf("invalid bootstrap resource name")
@@ -154,7 +158,9 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
if err != nil {
return err
}
out.ExpiresAt = now.Add(30 * 24 * time.Hour)
if in.ValidFor > 0 {
out.ExpiresAt = timePtr(now.Add(in.ValidFor))
}
if in.ExistingApplicationID == "" {
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_applications(id,name) VALUES (?,?)", out.ApplicationID, in.Application); err != nil {
return err

View File

@@ -117,16 +117,16 @@ func (s *ConsoleApplicationStore) GetApplication(ctx context.Context, token auth
if err != nil {
return err
}
rows, err = tx.QueryContext(ctx, `SELECT id,name,revision,expires_at,revoked_at,permissions FROM orbit_credentials WHERE namespace_id=? ORDER BY id LIMIT 101`, namespace)
rows, err = tx.QueryContext(ctx, `SELECT id,name,revision,expires_at,revoked_at,previous_valid_until,permissions FROM orbit_credentials WHERE namespace_id=? ORDER BY id LIMIT 101`, namespace)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var c consoleauth.AccessCredential
var expires, revoked sql.NullTime
var expires, revoked, previous sql.NullTime
var raw []byte
if err := rows.Scan(&c.ID, &c.Name, &c.Revision, &expires, &revoked, &raw); err != nil {
if err := rows.Scan(&c.ID, &c.Name, &c.Revision, &expires, &revoked, &previous, &raw); err != nil {
return err
}
if err := json.Unmarshal(raw, &c.Permissions); err != nil {
@@ -134,6 +134,7 @@ func (s *ConsoleApplicationStore) GetApplication(ctx context.Context, token auth
}
c.ExpiresAt = nullableTime(expires)
c.RevokedAt = nullableTime(revoked)
c.PreviousValidUntil = nullableTime(previous)
out.Credentials = append(out.Credentials, c)
}
if len(out.Queues) > 100 {
@@ -181,8 +182,14 @@ func applicationPermissions(in consoleauth.ApplicationInput) (auth.Permissions,
return p, p.Validate()
}
func validateApplicationInput(in consoleauth.ApplicationInput) error {
if in.ValidDays < 1 || in.ValidDays > 365 {
return fault.Invalid("valid_days must be 1-365")
if in.ValidDays < 0 || in.ValidDays > 365 {
return fault.Invalid("valid_days must be 0 (permanent) or 1-365")
}
if in.GraceDays < 0 || in.GraceDays > 30 || (in.Kind != "ROTATE" && in.GraceDays != 0) {
return fault.Invalid("grace_days must be 0-30 for rotation only")
}
if in.Kind == "FINALIZE" && in.ValidDays != 0 {
return fault.Invalid("finalize cannot set validity")
}
if err := validCredentialReason(in.Reason); err != nil {
return err
@@ -194,17 +201,17 @@ func validateApplicationInput(in consoleauth.ApplicationInput) error {
}
_, err := applicationPermissions(in)
return err
case "ROTATE":
case "ROTATE", "SET_EXPIRY", "FINALIZE":
if in.NamespaceID.Validate() != nil || in.CredentialID.Validate() != nil || in.ExpectedRevision == 0 || in.Application != "" || in.Namespace != "" || in.Environment != "" || in.Queue != "" || len(in.Capabilities) != 0 {
return fault.Invalid("invalid rotation fields")
}
default:
return fault.Invalid("kind must be CREATE or ROTATE")
return fault.Invalid("kind must be CREATE, ROTATE, SET_EXPIRY or FINALIZE")
}
return nil
}
func (s *ConsoleApplicationStore) PrepareApplication(ctx context.Context, token auth.Token, in consoleauth.ApplicationInput) (consoleauth.ApplicationOperation, error) {
out := consoleauth.ApplicationOperation{Input: in}
out := consoleauth.ApplicationOperation{Version: 2, Input: in}
if err := validateApplicationInput(in); err != nil {
return out, err
}
@@ -216,7 +223,12 @@ func (s *ConsoleApplicationStore) PrepareApplication(ctx context.Context, token
if err != nil {
return err
}
out.ExpiresAt = now.Add(time.Duration(in.ValidDays) * 24 * time.Hour)
if in.ValidDays > 0 {
out.ExpiresAt = timePtr(now.Add(time.Duration(in.ValidDays) * 24 * time.Hour))
}
if in.GraceDays > 0 {
out.PreviousValidUntil = timePtr(now.Add(time.Duration(in.GraceDays) * 24 * time.Hour))
}
out.OperationID, err = core.NewID()
if err != nil {
return err
@@ -243,7 +255,9 @@ func (s *ConsoleApplicationStore) PrepareApplication(ctx context.Context, token
return fault.Conflict
}
}
out.Token, _, err = auth.Issue(out.NamespaceID, out.CredentialID)
if in.Kind == "CREATE" || in.Kind == "ROTATE" {
out.Token, _, err = auth.Issue(out.NamespaceID, out.CredentialID)
}
return err
})
return out, err
@@ -253,15 +267,21 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au
if err := validateApplicationInput(op.Input); err != nil {
return out, err
}
parsed, err := auth.Parse(op.Token)
if err != nil || parsed.NamespaceID != op.NamespaceID || parsed.CredentialID != op.CredentialID || op.OperationID.Validate() != nil || op.ApplicationID.Validate() != nil || op.ExpiresAt.IsZero() {
return out, fault.Invalid("invalid fixed operation")
if (op.Version != 0 && op.Version != 2) || op.OperationID.Validate() != nil || op.ApplicationID.Validate() != nil || op.NamespaceID.Validate() != nil || op.CredentialID.Validate() != nil {
return out, fault.Invalid("invalid fixed operation identity")
}
if op.Input.Kind == "ROTATE" {
if (op.Input.ValidDays == 0) != (op.ExpiresAt == nil) || (op.Input.GraceDays == 0) != (op.PreviousValidUntil == nil) {
return out, fault.Invalid("fixed lifetime does not match input")
}
version := op.Version
if version == 0 {
version = 1
}
if op.Input.Kind != "CREATE" {
if op.QueueID != "" || op.NamespaceID != op.Input.NamespaceID || op.CredentialID != op.Input.CredentialID {
return out, fault.Invalid("rotation identity mismatch")
return out, fault.Invalid("credential identity mismatch")
}
change := CredentialOperation{Version: 1, OperationID: op.OperationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, ExpectedRevision: op.Input.ExpectedRevision, Action: "ROTATE", Reason: op.Input.Reason, Token: op.Token, ExpiresAt: &op.ExpiresAt}
change := CredentialOperation{Version: version, OperationID: op.OperationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, ExpectedRevision: op.Input.ExpectedRevision, Action: op.Input.Kind, Reason: op.Input.Reason, Token: op.Token, ExpiresAt: op.ExpiresAt, PreviousValidUntil: op.PreviousValidUntil}
receipt, err := applyCredentialOperation(ctx, s.db, change, func(tx *sql.Tx) (string, error) {
actor, err := s.admin(ctx, tx, token)
if err != nil {
@@ -282,6 +302,10 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au
})
return consoleauth.ApplicationReceipt{OperationID: op.OperationID, ApplicationID: op.ApplicationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, Revision: receipt.Revision, Deduplicated: receipt.Deduplicated}, err
}
parsed, err := auth.Parse(op.Token)
if err != nil || parsed.NamespaceID != op.NamespaceID || parsed.CredentialID != op.CredentialID || (version == 1 && op.ExpiresAt == nil) {
return out, fault.Invalid("invalid new credential")
}
if op.QueueID.Validate() != nil {
return out, fault.Invalid("invalid queue identity")
}
@@ -325,7 +349,7 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au
if err != nil {
return err
}
if !op.ExpiresAt.After(now) || op.ExpiresAt.After(now.Add(365*24*time.Hour)) {
if op.ExpiresAt != nil && (!op.ExpiresAt.After(now) || op.ExpiresAt.After(now.Add(365*24*time.Hour))) {
return fault.Invalid("expired or excessive credential lifetime")
}
statements := []struct {
@@ -336,7 +360,7 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au
{`INSERT INTO orbit_application_resources(application_id) VALUES (?)`, []any{op.ApplicationID}},
{`INSERT INTO orbit_namespaces(id,application_id,name,environment) VALUES (?,?,?,?)`, []any{op.NamespaceID, op.ApplicationID, op.Input.Namespace, op.Input.Environment}},
{`INSERT INTO pulse_queues(namespace_id,id,name,max_concurrency,rate_per_second,policy) VALUES (?,?,?,4,10,'{}')`, []any{op.NamespaceID, op.QueueID, op.Input.Queue}},
{`INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at) VALUES (?,?,'console-issued',?,?,?)`, []any{op.NamespaceID, op.CredentialID, parsed.Digest, string(raw), op.ExpiresAt.UTC()}},
{`INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at) VALUES (?,?,'console-issued',?,?,?)`, []any{op.NamespaceID, op.CredentialID, parsed.Digest, string(raw), op.ExpiresAt}},
}
for _, st := range statements {
if _, err := tx.ExecContext(ctx, st.query, st.args...); err != nil {

View File

@@ -150,7 +150,7 @@ func TestIntegrationCredentialCommitFaults(t *testing.T) {
if err := Bootstrap(ctx, db, BootstrapInput{Application: name, Namespace: "default", Environment: "test", Queue: "credential"}, func(v BootstrapResult) error { initial = v; return nil }); err != nil {
t.Fatal(err)
}
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 1, Action: credentialRotateAction, ValidFor: time.Hour, Reason: "commit boundary"})
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 1, Action: credentialRotateAction, ValidFor: time.Hour, GraceFor: time.Hour, Reason: "commit boundary"})
if err != nil {
t.Fatal(err)
}
@@ -175,6 +175,14 @@ func TestIntegrationCredentialCommitFaults(t *testing.T) {
if after {
wantRevision = 2
}
oldToken, _ := auth.Parse(initial.Token)
newToken, _ := auth.Parse(op.Token)
if err := NewTaskStore(db).AuthorizeSubmit(ctx, oldToken); err != nil {
t.Fatal("commit ambiguity invalidated old token", err)
}
if accepted := NewTaskStore(db).AuthorizeSubmit(ctx, newToken) == nil; accepted != after {
t.Fatal("commit boundary accepted wrong generation")
}
if status.Revision != wantRevision {
t.Fatalf("commit %v status revision=%d want=%d", after, status.Revision, wantRevision)
}

View File

@@ -0,0 +1,283 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/internal/transport/httpserver"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
"git.yuntuo.hk/k2safe/orbit/pulse/worker"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
)
func overlapDB(t *testing.T) (context.Context, *sql.DB) {
t.Helper()
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("requires disposable MySQL")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
t.Cleanup(cancel)
db, err := Open(ctx, dsn, 12, 3)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
if err = Migrate(ctx, db); err != nil {
t.Fatal(err)
}
return ctx, db
}
func TestIntegrationCredentialOverlap(t *testing.T) {
ctx, db := overlapDB(t)
f := newExecutionFixture(t, ctx, db)
old := f.token
// 先使用有限有效期,再原地改长期;在途授权、秘密及绑定均不改变。
change := func(action string, revision uint64, grace time.Duration) CredentialOperation {
t.Helper()
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: old.NamespaceID, CredentialID: old.CredentialID, ExpectedRevision: revision, Action: action, GraceFor: grace, Reason: "credential overlap regression"})
if err != nil {
t.Fatal(err)
}
return op
}
f.exec(`UPDATE orbit_credentials SET expires_at=DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 30 DAY) WHERE namespace_id=? AND id=?`, old.NamespaceID, old.CredentialID)
task := f.submit(1)
_, ref := f.claim()
if _, err := f.a.Start(ctx, old, f.binding, testID(t), ref); err != nil {
t.Fatal(err)
}
expiry := change(credentialExpiryAction, 1, 0)
receipt, err := ApplyCredentialOperation(ctx, db, expiry)
if err != nil || receipt.ExpiresAt != nil || expiry.Token != "" {
t.Fatalf("expiry update: %v", err)
}
if err := f.a.Authorize(ctx, old); err != nil {
t.Fatal("original token lost after expiry update", err)
}
rotation := change(credentialRotateAction, 2, 7*24*time.Hour)
var wg sync.WaitGroup
receipts := make([]CredentialChangeReceipt, 2)
errs := make([]error, 2)
for i := range receipts {
wg.Add(1)
go func(i int) { defer wg.Done(); receipts[i], errs[i] = ApplyCredentialOperation(ctx, db, rotation) }(i)
}
wg.Wait()
if errs[0] != nil || errs[1] != nil || receipts[0].Deduplicated == receipts[1].Deduplicated {
t.Fatalf("concurrent replay: %v", errs)
}
current, _ := auth.Parse(rotation.Token)
for _, token := range []auth.Token{old, current} {
if err := f.a.Authorize(ctx, token); err != nil {
t.Fatal("overlap auth rejected", err)
}
}
status, err := GetCredentialStatus(ctx, db, old.NamespaceID, old.CredentialID)
if err != nil || status.ExpiresAt != nil || status.PreviousValidUntil == nil {
t.Fatal("missing permanent/overlap status", err)
}
originalUntil := *status.PreviousValidUntil
// 过渡期间第二次轮换必须冲突,而不能挤掉仍在工作的上一代。
blocked := change(credentialRotateAction, 3, time.Hour)
if _, err := ApplyCredentialOperation(ctx, db, blocked); fault.Code(err) != "STATE_CONFLICT" {
t.Fatal("active predecessor overwritten", err)
}
renewed, err := f.a.Renew(ctx, old, f.binding, testID(t), ref)
if err != nil || !renewed.Lease.Active {
t.Fatal("in-flight old-token renew failed", err)
}
if _, err := f.a.Finish(ctx, old, f.binding, testID(t), worker.FinishInput{Ref: ref, Outcome: "SUCCEEDED", Result: json.RawMessage(`{"ok":true}`)}); err != nil {
t.Fatal("in-flight finish rejected", err)
}
if f.get(task.ID).State != pulse.TaskSucceeded || f.used() != 0 {
t.Fatal("task/permit changed by rotation")
}
final := change(credentialFinalizeAction, 3, 0)
if _, err := ApplyCredentialOperation(ctx, db, final); err != nil {
t.Fatal(err)
}
if err := f.a.Authorize(ctx, old); fault.Code(err) != "UNAUTHENTICATED" {
t.Fatal("retired token accepted", err)
}
if err := f.a.Authorize(ctx, current); err != nil {
t.Fatal("current token rejected", err)
}
replay, err := ApplyCredentialOperation(ctx, db, rotation)
if err != nil || !replay.Deduplicated || !replay.PreviousValidUntil.Equal(originalUntil) {
t.Fatal("old receipt drifted", err)
}
if err := f.a.Authorize(ctx, old); err == nil {
t.Fatal("replay resurrected predecessor")
}
// 整张凭据撤销必须同时使两代令牌失效。
next := change(credentialRotateAction, 4, time.Hour)
if _, err := ApplyCredentialOperation(ctx, db, next); err != nil {
t.Fatal(err)
}
newest, _ := auth.Parse(next.Token)
revoke := change(credentialRevokeAction, 5, 0)
if _, err := ApplyCredentialOperation(ctx, db, revoke); err != nil {
t.Fatal(err)
}
for _, token := range []auth.Token{old, current, newest} {
if err := f.a.Authorize(ctx, token); fault.Code(err) != "UNAUTHENTICATED" {
t.Fatal("revocation missed a generation", err)
}
}
var audits string
if err := db.QueryRowContext(ctx, `SELECT CAST(JSON_ARRAYAGG(JSON_OBJECT('before',`+"`before`"+`,'after',`+"`after`"+`)) AS CHAR) FROM orbit_audit_events WHERE namespace_id=? AND resource_kind='credential'`, old.NamespaceID).Scan(&audits); err != nil {
t.Fatal(err)
}
if strings.Contains(audits, rotation.Token) || strings.Contains(audits, "secret_digest") {
t.Fatal("audit leaked secret")
}
}
func TestIntegrationCredentialOverlapDeadline(t *testing.T) {
ctx, db := overlapDB(t)
f := newExecutionFixture(t, ctx, db)
old := f.token
f.exec(`UPDATE orbit_credentials SET expires_at=DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 1 HOUR) WHERE namespace_id=? AND id=?`, old.NamespaceID, old.CredentialID)
before, err := GetCredentialStatus(ctx, db, old.NamespaceID, old.CredentialID)
if err != nil {
t.Fatal(err)
}
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: old.NamespaceID, CredentialID: old.CredentialID, ExpectedRevision: 1, Action: credentialRotateAction, GraceFor: 24 * time.Hour, Reason: "bounded grace"})
if err != nil {
t.Fatal(err)
}
out, err := ApplyCredentialOperation(ctx, db, op)
if err != nil || !out.PreviousValidUntil.Equal(*before.ExpiresAt) {
t.Fatal("grace extended original expiry", err)
}
current, _ := auth.Parse(op.Token)
// 新令牌过期不会改变独立的旧令牌截止;旧令牌也不能越过自己的截止。
f.exec(`UPDATE orbit_credentials SET expires_at=DATE_SUB(UTC_TIMESTAMP(6),INTERVAL 1 SECOND) WHERE namespace_id=? AND id=?`, old.NamespaceID, old.CredentialID)
if f.a.Authorize(ctx, old) != nil || fault.Code(f.a.Authorize(ctx, current)) != "UNAUTHENTICATED" {
t.Fatal("expiry of two generations conflated")
}
f.exec(`UPDATE orbit_credentials SET expires_at=NULL,previous_valid_until=DATE_SUB(UTC_TIMESTAMP(6),INTERVAL 1 MICROSECOND) WHERE namespace_id=? AND id=?`, old.NamespaceID, old.CredentialID)
if f.a.Authorize(ctx, current) != nil || fault.Code(f.a.Authorize(ctx, old)) != "UNAUTHENTICATED" {
t.Fatal("expired old token remained usable")
}
// 过期的旧令牌不需定时清理才能失效;下一轮可以重用单一旧代槽。
next, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: old.NamespaceID, CredentialID: old.CredentialID, ExpectedRevision: 2, Action: credentialRotateAction, GraceFor: time.Hour, Reason: "next rotation"})
if err != nil {
t.Fatal(err)
}
if _, err := ApplyCredentialOperation(ctx, db, next); err != nil {
t.Fatal(err)
}
if f.a.Authorize(ctx, current) != nil || fault.Code(f.a.Authorize(ctx, old)) != "UNAUTHENTICATED" {
t.Fatal("wrong predecessor reused")
}
}
// 两个独立 HTTP/WS 节点共享真实 MySQL,验证旧长连接命令和跨节点重连。
func TestIntegrationCredentialOverlapWebSocket(t *testing.T) {
ctx, db := overlapDB(t)
f := newExecutionFixture(t, ctx, db)
serve := func(service *worker.Service) *httptest.Server {
hub := httpserver.NewWorkerHub(ctx, service)
server := httpserver.New("", nil, f.tasks)
httpserver.RegisterWorkers(server, hub)
ts := httptest.NewServer(server.Handler)
t.Cleanup(func() {
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := hub.Shutdown(shutdown); err != nil {
t.Error(err)
}
ts.Close()
})
return ts
}
a, b := serve(f.a), serve(f.b)
hello := f.hello
hello.InstanceID = testID(t)
hello.Capabilities = []worker.Capability{{Handler: "rotation.idle", HandlerVersion: "v1", PayloadVersion: "v1"}}
type frame struct {
Type string `json:"type"`
SessionID core.ID `json:"session_id"`
Error *fault.Error `json:"error"`
}
connect := func(url, token string) (*websocket.Conn, core.ID) {
t.Helper()
c, _, err := websocket.Dial(ctx, url+"/v1/worker/connect", &websocket.DialOptions{HTTPHeader: http.Header{"Authorization": []string{"Bearer " + token}}, Subprotocols: []string{"pulse.worker.v1"}})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { c.CloseNow() })
if err := wsjson.Write(ctx, c, map[string]any{"protocol_version": "1", "type": "worker.hello", "message_id": testID(t), "body": hello}); err != nil {
t.Fatal(err)
}
var out frame
if err := wsjson.Read(ctx, c, &out); err != nil || out.Type != "worker.welcome" {
t.Fatalf("WS welcome: %v, %s", err, out.Type)
}
return c, out.SessionID
}
capacity := func(c *websocket.Conn, session core.ID, seq int, want string) {
t.Helper()
body := worker.CapacityInput{Sequence: uint64(seq), Capacity: 0}
if err := wsjson.Write(ctx, c, map[string]any{"protocol_version": "1", "type": "worker.capacity", "message_id": testID(t), "session_id": session, "body": body}); err != nil {
t.Fatal(err)
}
var out frame
if err := wsjson.Read(ctx, c, &out); err != nil {
t.Fatal(err)
}
code := ""
if out.Error != nil {
code = out.Error.Code
}
if code != want {
t.Fatalf("WS auth: %s, want %s", code, want)
}
}
oldConn, session := connect(a.URL, f.resources.Token)
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: f.token.NamespaceID, CredentialID: f.token.CredentialID, ExpectedRevision: 1, Action: credentialRotateAction, GraceFor: time.Hour, Reason: "rolling switch"})
if err != nil {
t.Fatal(err)
}
if _, err := ApplyCredentialOperation(ctx, db, op); err != nil {
t.Fatal(err)
}
capacity(oldConn, session, 1, "")
oldConn.CloseNow()
reconnected, oldSession := connect(b.URL, f.resources.Token)
capacity(reconnected, oldSession, 1, "")
hello.InstanceID = testID(t)
current, newSession := connect(a.URL, op.Token)
capacity(current, newSession, 1, "")
final, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: f.token.NamespaceID, CredentialID: f.token.CredentialID, ExpectedRevision: 2, Action: credentialFinalizeAction, Reason: "all services switched"})
if err != nil {
t.Fatal(err)
}
if _, err := ApplyCredentialOperation(ctx, db, final); err != nil {
t.Fatal(err)
}
capacity(reconnected, oldSession, 2, "UNAUTHENTICATED")
capacity(current, newSession, 2, "")
c, response, err := websocket.Dial(ctx, b.URL+"/v1/worker/connect", &websocket.DialOptions{HTTPHeader: http.Header{"Authorization": []string{"Bearer " + f.resources.Token}}, Subprotocols: []string{"pulse.worker.v1"}})
if c != nil {
c.CloseNow()
}
if err == nil || response == nil || response.StatusCode != 401 {
t.Fatal("retired WS handshake accepted")
}
}

View File

@@ -16,9 +16,11 @@ import (
)
const (
credentialOperationVersion = 1
credentialOperationVersion = 2
credentialRotateAction = "ROTATE"
credentialRevokeAction = "REVOKE"
credentialExpiryAction = "SET_EXPIRY"
credentialFinalizeAction = "FINALIZE"
credentialOperationName = "credential.change.v1"
)
@@ -33,6 +35,8 @@ type CredentialStatus struct {
Name string `json:"name"`
// Revision 凭据的乐观并发版本,所有变更都必须核对它。
Revision uint64 `json:"revision"`
// PreviousValidUntil 上一代令牌可用的 UTC 截止;空表示无旧令牌。
PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"`
// ExpiresAt 当前秘密的到期时间;空值表示未设置到期时间。
ExpiresAt *time.Time `json:"expires_at,omitempty"`
// RevokedAt 撤销时间;空值表示尚未撤销。
@@ -52,9 +56,11 @@ type CredentialChangeInput struct {
CredentialID core.ID
// ExpectedRevision 准备操作时观察到的版本。
ExpectedRevision uint64
// Action 只能是 ROTATE 或 REVOKE。
// Action 为 ROTATE、REVOKE、SET_EXPIRY 或 FINALIZE。
Action string
// ValidFor 轮换后的有效时长;撤销时必须为零。
// GraceFor 轮换时旧令牌的共存时长;零为立即失效,最多 30 天。
GraceFor time.Duration
// ValidFor 新令牌或原令牌的新有效时长;零为长期,撤销/结束过渡时必须为零。
ValidFor time.Duration
// Reason 必填审计原因。
Reason string
@@ -73,13 +79,15 @@ type CredentialOperation struct {
CredentialID core.ID `json:"credential_id"`
// ExpectedRevision 只允许在该版本上首次应用。
ExpectedRevision uint64 `json:"expected_revision"`
// Action 固定为 ROTATE 或 REVOKE。
// Action 固定变更类型;格式 v1 仅支持立即 ROTATE 和 REVOKE。
Action string `json:"action"`
// Reason 写入管理审计,不能包含控制字符或秘密。
Reason string `json:"reason"`
// Token 轮换产生的一次性新令牌;撤销操作为空。
Token string `json:"token,omitempty"`
// ExpiresAt 轮换后固定的绝对到期时间;重放不能重新计算。
// PreviousValidUntil ROTATE 请求的固定过渡截止;实际不超过旧令牌原到期时间。
PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"`
// ExpiresAt 轮换/调整有效期后的固定绝对时间;v2 为空表示长期,重放不能重新计算。
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
@@ -98,7 +106,9 @@ type CredentialChangeReceipt struct {
Revision uint64 `json:"revision"`
// AppliedAt 操作提交事务使用的数据库时间。
AppliedAt time.Time `json:"applied_at"`
// ExpiresAt 轮换后的固定到期时间。
// PreviousValidUntil 操作后的旧令牌截止;重报返回首次提交时的值。
PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"`
// ExpiresAt 当前令牌的固定到期时间,空为长期。
ExpiresAt *time.Time `json:"expires_at,omitempty"`
// RevokedAt 撤销提交时间。
RevokedAt *time.Time `json:"revoked_at,omitempty"`
@@ -115,13 +125,13 @@ func GetCredentialStatus(ctx context.Context, db *sql.DB, namespace, credential
if err := CheckSchema(ctx, db); err != nil {
return out, err
}
var expires, revoked sql.NullTime
err := db.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,n.enabled,a.enabled
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
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, &out.NamespaceEnabled, &out.ApplicationEnabled)
Scan(&out.Name, &out.Revision, &expires, &revoked, &previousUntil, &out.NamespaceEnabled, &out.ApplicationEnabled)
if err == sql.ErrNoRows {
return out, fault.NotFound
}
@@ -130,6 +140,7 @@ func GetCredentialStatus(ctx context.Context, db *sql.DB, namespace, credential
}
out.NamespaceID, out.CredentialID = namespace, credential
out.ExpiresAt = nullableTime(expires)
out.PreviousValidUntil = nullableTime(previousUntil)
out.RevokedAt = nullableTime(revoked)
return out, nil
}
@@ -162,9 +173,12 @@ func PrepareCredentialOperation(ctx context.Context, db *sql.DB, in CredentialCh
return out, err
}
out.Token = secret
if in.GraceFor > 0 {
out.PreviousValidUntil = timePtr(now.Add(in.GraceFor))
}
}
if in.ValidFor > 0 {
out.ExpiresAt = timePtr(now.Add(in.ValidFor))
} else if in.ValidFor != 0 {
return out, fault.Invalid("revoke operation cannot set validity")
}
out.Version = credentialOperationVersion
out.NamespaceID = in.NamespaceID
@@ -182,25 +196,28 @@ 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 != credentialOperationVersion || op.OperationID.Validate() != nil || op.NamespaceID.Validate() != nil || op.CredentialID.Validate() != nil {
if (op.Version != 1 && op.Version != credentialOperationVersion) || 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 || (op.Action != credentialRotateAction && op.Action != credentialRevokeAction) {
if op.ExpectedRevision == 0 || !validCredentialAction(op.Action) {
return fault.Invalid("credential operation action or revision is invalid")
}
if err := validCredentialReason(op.Reason); err != nil {
return err
}
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")
}
if op.ExpiresAt != nil && op.ExpiresAt.IsZero() || op.PreviousValidUntil != nil && op.PreviousValidUntil.IsZero() {
return fault.Invalid("fixed times must not be zero")
}
if op.Action == credentialRotateAction {
if op.ExpiresAt == nil || op.ExpiresAt.IsZero() || op.Token == "" {
return fault.Invalid("rotation operation must contain a token and fixed expiry")
}
parsed, err := auth.Parse(op.Token)
if err != nil || parsed.NamespaceID != op.NamespaceID || parsed.CredentialID != op.CredentialID {
return fault.Invalid("rotation token does not match credential identity")
}
} else if op.Token != "" || op.ExpiresAt != nil {
return fault.Invalid("revoke operation must not contain a token or expiry")
} else if op.Token != "" || op.PreviousValidUntil != nil || (op.Action != credentialExpiryAction && op.ExpiresAt != nil) {
return fault.Invalid("invalid fields for credential change")
}
return nil
}
@@ -232,14 +249,14 @@ func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper
}
var name string
var revision uint64
var expires, revoked sql.NullTime
var expires, revoked, previousUntil sql.NullTime
var namespaceEnabled, applicationEnabled bool
err := tx.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,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
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, &namespaceEnabled, &applicationEnabled)
Scan(&name, &revision, &expires, &revoked, &previousUntil, &namespaceEnabled, &applicationEnabled)
if err == sql.ErrNoRows {
return fault.NotFound
}
@@ -267,32 +284,72 @@ func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper
if err != nil {
return err
}
before := map[string]any{"name": name, "revision": revision, "expires_at": nullableTime(expires), "revoked_at": nullableTime(revoked), "namespace_enabled": namespaceEnabled, "application_enabled": applicationEnabled}
before := map[string]any{"name": name, "revision": revision, "expires_at": nullableTime(expires), "revoked_at": nullableTime(revoked), "previous_valid_until": nullableTime(previousUntil), "namespace_enabled": namespaceEnabled, "application_enabled": applicationEnabled}
after := map[string]any{"name": name, "revision": revision + 1, "namespace_enabled": namespaceEnabled, "application_enabled": applicationEnabled}
if op.Action == credentialRotateAction {
parsed, err := auth.Parse(op.Token)
if err != nil {
return fault.Invalid("rotation token is invalid")
}
if !op.ExpiresAt.After(now) || op.ExpiresAt.After(now.Add(365*24*time.Hour)) {
if op.ExpiresAt != nil && (!op.ExpiresAt.After(now) || op.ExpiresAt.After(now.Add(365*24*time.Hour))) {
return fault.Conflict
}
out.ExpiresAt = nullableTime(expires)
out.PreviousValidUntil = nullableTime(previousUntil)
switch op.Action {
case credentialRotateAction:
parsed, _ := auth.Parse(op.Token)
// 仍在过渡期时不覆盖上一代,避免把尚未切换的服务突然踢下线。
if previousUntil.Valid && previousUntil.Time.After(now) {
return fault.Conflict
}
var previousDigest any
out.PreviousValidUntil = nil
if op.PreviousValidUntil != nil {
if !op.PreviousValidUntil.After(now) || op.PreviousValidUntil.After(now.Add(30*24*time.Hour)) {
return fault.Conflict
}
until := *op.PreviousValidUntil
if expires.Valid && expires.Time.Before(until) {
until = expires.Time
}
if until.After(now) {
var old core.Digest
if err := tx.QueryRowContext(ctx, `SELECT secret_digest FROM orbit_credentials WHERE namespace_id=? AND id=?`, op.NamespaceID, op.CredentialID).Scan(&old); err != nil {
return err
}
if subtleCompareDigest(old, parsed.Digest) == 1 {
return fault.Invalid("rotation must use a new secret")
}
previousDigest = old
out.PreviousValidUntil = timePtr(until)
}
}
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials
SET secret_digest=?,expires_at=?,revoked_at=NULL,revision=revision+1,updated_at=?
WHERE namespace_id=? AND id=? AND revision=?`, parsed.Digest, op.ExpiresAt.UTC(), now, op.NamespaceID, op.CredentialID, revision); err != nil {
SET secret_digest=?,expires_at=?,previous_secret_digest=?,previous_valid_until=?,revision=revision+1,updated_at=?
WHERE namespace_id=? AND id=? AND revision=?`, parsed.Digest, op.ExpiresAt, previousDigest, out.PreviousValidUntil, now, op.NamespaceID, op.CredentialID, revision); err != nil {
return err
}
out.ExpiresAt = timePtr(op.ExpiresAt.UTC())
after["expires_at"] = out.ExpiresAt
} else {
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials
SET revoked_at=?,revision=revision+1,updated_at=?
WHERE namespace_id=? AND id=? AND revision=?`, now, now, op.NamespaceID, op.CredentialID, revision); err != nil {
out.ExpiresAt = op.ExpiresAt
case credentialExpiryAction:
// 只变更当前令牌有效期;不能延长上一代截止或改变任何秘密。
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET expires_at=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, op.ExpiresAt, now, op.NamespaceID, op.CredentialID, revision); err != nil {
return err
}
out.ExpiresAt = op.ExpiresAt
case credentialFinalizeAction:
if !previousUntil.Valid {
return fault.Conflict
}
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET previous_secret_digest=NULL,previous_valid_until=NULL,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, now, op.NamespaceID, op.CredentialID, revision); err != nil {
return err
}
out.PreviousValidUntil = nil
case credentialRevokeAction:
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET revoked_at=?,previous_secret_digest=NULL,previous_valid_until=NULL,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, now, now, op.NamespaceID, op.CredentialID, revision); err != nil {
return err
}
out.RevokedAt = timePtr(now)
after["revoked_at"] = now
out.PreviousValidUntil = nil
}
after["expires_at"] = out.ExpiresAt
after["previous_valid_until"] = out.PreviousValidUntil
after["revoked_at"] = out.RevokedAt
out.OperationID = op.OperationID
out.NamespaceID = op.NamespaceID
out.CredentialID = op.CredentialID
@@ -410,17 +467,20 @@ func validateCredentialChangeInput(in CredentialChangeInput) error {
if err := validateCredentialIDs(in.NamespaceID, in.CredentialID); err != nil {
return err
}
if in.ExpectedRevision == 0 || (in.Action != credentialRotateAction && in.Action != credentialRevokeAction) {
if in.ExpectedRevision == 0 || !validCredentialAction(in.Action) {
return fault.Invalid("credential action and expected revision are required")
}
if err := validCredentialReason(in.Reason); err != nil {
return err
}
if in.Action == credentialRotateAction && (in.ValidFor < time.Hour || in.ValidFor > 365*24*time.Hour) {
return fault.Invalid("credential validity must be between one hour and one year")
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.ValidFor != 0 {
return fault.Invalid("revoke operation cannot set validity")
if (in.Action == credentialRevokeAction || in.Action == credentialFinalizeAction) && 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")
}
return nil
}
@@ -444,3 +504,7 @@ func timePtr(v time.Time) *time.Time {
t := v.UTC()
return &t
}
func validCredentialAction(action string) bool {
return action == credentialRotateAction || action == credentialRevokeAction || action == credentialExpiryAction || action == credentialFinalizeAction
}

View File

@@ -26,7 +26,7 @@ var migrationFiles embed.FS
//go:embed baseline.sql
var freshInstallBaseline string
const freshInstallBaselineVersion uint64 = 31
const freshInstallBaselineVersion uint64 = 32
type Migration struct {
Version uint64

View File

@@ -0,0 +1,5 @@
-- 同一凭据最多保留一代旧秘密;截止时间由轮换事务固定,不依赖节点内存。
ALTER TABLE orbit_credentials
ADD COLUMN previous_secret_digest BINARY(32) NULL COMMENT '上一代秘密的 SHA-256 摘要;为空表示无过渡令牌,不保存原文',
ADD COLUMN previous_valid_until DATETIME(6) NULL COMMENT '上一代令牌过渡截止,UTC;不晚于其原到期时间,与旧摘要同时为空或非空',
ADD CONSTRAINT ck_credential_overlap CHECK ((previous_secret_digest IS NULL AND previous_valid_until IS NULL) OR (previous_secret_digest IS NOT NULL AND previous_valid_until IS NOT NULL));

View File

@@ -46,18 +46,23 @@ func (s *TaskStore) transaction(ctx context.Context, token auth.Token, action st
}
func authenticate(ctx context.Context, tx *sql.Tx, token auth.Token) (auth.Permissions, error) {
var digest core.Digest
var raw []byte
err := tx.QueryRowContext(ctx, `SELECT c.secret_digest,c.permissions FROM orbit_credentials c
var raw, previous []byte
var currentValid, previousValid bool
err := tx.QueryRowContext(ctx, `SELECT c.secret_digest,c.permissions,c.previous_secret_digest,
(c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6)),
COALESCE(c.previous_valid_until>UTC_TIMESTAMP(6),FALSE) 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=? AND c.revoked_at IS NULL
AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6)) AND n.enabled=TRUE AND a.enabled=TRUE FOR SHARE`, token.NamespaceID, token.CredentialID).Scan(&digest, &raw)
AND n.enabled=TRUE AND a.enabled=TRUE FOR SHARE`, token.NamespaceID, token.CredentialID).Scan(&digest, &raw, &previous, &currentValid, &previousValid)
if errors.Is(err, sql.ErrNoRows) {
return auth.Permissions{}, fault.Unauthorized
}
if err != nil {
return auth.Permissions{}, err
}
if subtle.ConstantTimeCompare(digest[:], token.Digest[:]) != 1 {
currentMatch := subtle.ConstantTimeCompare(digest[:], token.Digest[:])
previousMatch := subtle.ConstantTimeCompare(previous, token.Digest[:])
if !(currentValid && currentMatch == 1) && !(previousValid && previousMatch == 1) {
// 控制台会话使用独立的短期令牌摘要,不轮换也不覆盖业务 SDK
// 凭据。只有经过会话表、账号、命名空间和应用启停状态联合校验,
// 才允许沿用该业务凭据的权限快照。