feat: support permanent credentials and overlapping token rotation
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 '资源所属命名空间,参与主键、授权与关联校验',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
283
internal/storage/mysql/credential_overlap_integration_test.go
Normal file
283
internal/storage/mysql/credential_overlap_integration_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
@@ -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, ¤tValid, &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
|
||||
// 凭据。只有经过会话表、账号、命名空间和应用启停状态联合校验,
|
||||
// 才允许沿用该业务凭据的权限快照。
|
||||
|
||||
Reference in New Issue
Block a user