feat: support permanent credentials and overlapping token rotation
This commit is contained in:
@@ -32,12 +32,13 @@ type AccessQueue struct {
|
||||
|
||||
// AccessCredential 是脱敏的凭据元数据,绝不返回秘密或秘密摘要。
|
||||
type AccessCredential struct {
|
||||
ID core.ID `json:"id"` // ID 凭据标识,重新生成 Token 时不改变。
|
||||
Name string `json:"name"` // Name 凭据名称。
|
||||
Revision uint64 `json:"revision,string"` // Revision 轮换的并发校验版本。
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"` // ExpiresAt 到期时间,空表示无固定到期时间。
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"` // RevokedAt 撤销时间,非空时禁止轮换。
|
||||
Permissions auth.Permissions `json:"permissions"` // Permissions 动作和队列授权范围。
|
||||
PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"` // PreviousValidUntil 上一代令牌实际截止,空表示无旧令牌。
|
||||
ID core.ID `json:"id"` // ID 凭据标识,重新生成 Token 时不改变。
|
||||
Name string `json:"name"` // Name 凭据名称。
|
||||
Revision uint64 `json:"revision,string"` // Revision 轮换的并发校验版本。
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"` // ExpiresAt 到期时间,空表示无固定到期时间。
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"` // RevokedAt 撤销时间,非空时禁止轮换。
|
||||
Permissions auth.Permissions `json:"permissions"` // Permissions 动作和队列授权范围。
|
||||
}
|
||||
|
||||
// ApplicationDetail 展示一个命名空间的队列和凭据,读取量有界。
|
||||
@@ -48,9 +49,9 @@ type ApplicationDetail struct {
|
||||
Truncated bool `json:"truncated"` // Truncated 为真时不得把当前结果解释为完整清单。
|
||||
}
|
||||
|
||||
// ApplicationInput 描述创建应用或轮换凭据的管理员意图。
|
||||
// ApplicationInput 描述创建应用或变更凭据的管理员意图。
|
||||
type ApplicationInput struct {
|
||||
Kind string `json:"kind"` // Kind CREATE 或 ROTATE。
|
||||
Kind string `json:"kind"` // Kind CREATE、ROTATE、SET_EXPIRY 或 FINALIZE。
|
||||
Application string `json:"application"` // Application 新应用名称,仅 CREATE 使用。
|
||||
Namespace string `json:"namespace"` // Namespace 新命名空间名称,仅 CREATE 使用。
|
||||
Environment string `json:"environment"` // Environment 新环境名称,仅 CREATE 使用。
|
||||
@@ -59,21 +60,24 @@ type ApplicationInput struct {
|
||||
NamespaceID core.ID `json:"namespace_id,omitempty"` // NamespaceID 轮换目标所属范围。
|
||||
CredentialID core.ID `json:"credential_id,omitempty"` // CredentialID 轮换目标凭据。
|
||||
ExpectedRevision uint64 `json:"expected_revision,string"` // ExpectedRevision 轮换时读取到的版本。
|
||||
ValidDays int `json:"valid_days"` // ValidDays 新令牌有效天数,1–365。
|
||||
ValidDays int `json:"valid_days"` // ValidDays 有效天数;0 为长期,1–365 为定期。
|
||||
GraceDays int `json:"grace_days,omitempty"` // GraceDays ROTATE 的旧令牌过渡天数,0–30;0 显式立即失效。
|
||||
Reason string `json:"reason"` // Reason 写入审计的变更原因。
|
||||
}
|
||||
|
||||
// ApplicationOperation 是浏览器在提交前保存的固定操作;Token 不写入数据库。
|
||||
// 网络结果未知时重放同一份内容,禁止重新生成操作身份或秘密。
|
||||
type ApplicationOperation struct {
|
||||
Input ApplicationInput `json:"input"` // Input 固定的管理员意图。
|
||||
OperationID core.ID `json:"operation_id"` // OperationID 稳定幂等身份。
|
||||
ApplicationID core.ID `json:"application_id"` // ApplicationID 新建或已核验的应用标识。
|
||||
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 固定命名空间标识。
|
||||
QueueID core.ID `json:"queue_id,omitempty"` // QueueID 新建应用的初始队列。
|
||||
CredentialID core.ID `json:"credential_id"` // CredentialID 固定凭据身份。
|
||||
Token string `json:"token"` // Token 一次性交付的秘密,只留在调用方内存或私密下载文件。
|
||||
ExpiresAt time.Time `json:"expires_at"` // ExpiresAt 首次准备时冻结的绝对到期时间。
|
||||
Version int `json:"version,omitempty"` // Version v2 支持长期令牌与过渡;旧固定操作未设置时沿用 v1。
|
||||
Input ApplicationInput `json:"input"` // Input 固定的管理员意图。
|
||||
OperationID core.ID `json:"operation_id"` // OperationID 稳定幂等身份。
|
||||
ApplicationID core.ID `json:"application_id"` // ApplicationID 新建或已核验的应用标识。
|
||||
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 固定命名空间标识。
|
||||
QueueID core.ID `json:"queue_id,omitempty"` // QueueID 新建应用的初始队列。
|
||||
CredentialID core.ID `json:"credential_id"` // CredentialID 固定凭据身份。
|
||||
Token string `json:"token,omitempty"` // Token 一次性交付的秘密,只留在调用方内存或私密下载文件。
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"` // ExpiresAt 固定到期时间,空表示长期或不适用。
|
||||
PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"` // PreviousValidUntil ROTATE 固定过渡截止。
|
||||
}
|
||||
|
||||
// ApplicationReceipt 是数据库持久化的脱敏回执。
|
||||
|
||||
@@ -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
|
||||
// 凭据。只有经过会话表、账号、命名空间和应用启停状态联合校验,
|
||||
// 才允许沿用该业务凭据的权限快照。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
(() => {
|
||||
const $ = id => document.getElementById(id);
|
||||
const ui = { after: '', next: '', selected: '', detail: null, loading: false, busy: false, pending: null, rotating: null, result: null, saved: true };
|
||||
const ui = { after: '', next: '', selected: '', detail: null, loading: false, busy: false, pending: null, rotating: null, result: null, saved: true, mode: 'CREATE' };
|
||||
const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const time = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '长期有效';
|
||||
function message(error) { return error.status === 403 ? '此账号未启用应用接入管理。请在服务端配置 ORBIT_CONSOLE_APPLICATION_ADMIN 为管理员登录名。' : error.message; }
|
||||
@@ -42,22 +42,30 @@
|
||||
$('access-credentials').innerHTML = detail.credentials.map(c => {
|
||||
const status = c.revoked_at ? '已撤销' : c.expires_at && new Date(c.expires_at) <= new Date() ? '已到期' : '有效';
|
||||
const groups = [['tasks:submit', '任务'], ['workers:consume', 'Worker 执行'], ['events:consume', '结果订阅'], ['schedules:manage', '定时调度'], ['workflows:start', '流程编排'], ['tasks:review', '人工恢复'], ['application:resources:manage', '应用额度']].filter(([action]) => c.permissions.actions.includes(action)).map(([,name]) => name);
|
||||
return `<section class="access-credential"><div class="access-credential-header"><b>${escape(c.name)} · ${status}</b><button class="text-button" data-access-rotate="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>重新生成 Token ↗</button></div><p>凭据 ID:${escape(c.id)}<br>有效期:${escape(time(c.expires_at))} · 版本 ${escape(c.revision)}<br>Token 原文不保存在数据库;已有服务继续使用原配置。</p><div class="access-permissions">${groups.map(a => `<span>${escape(a)}</span>`).join('')}</div><details class="access-permission-details"><summary>查看具体权限</summary><p>${c.permissions.actions.map(escape).join(' · ')}<br>授权队列:${escape(c.permissions.queues.join(', '))}</p></details></section>`;
|
||||
return `<section class="access-credential"><div class="access-credential-header"><b>${escape(c.name)} · ${status}</b><button class="text-button" data-access-rotate="${escape(c.id)}" ${c.revoked_at || c.previous_valid_until && new Date(c.previous_valid_until) > new Date() ? 'disabled' : ''}>平滑换证 ↗</button></div><p>凭据 ID:${escape(c.id)}<br>有效期:${escape(time(c.expires_at))} · 版本 ${escape(c.revision)}<br>Token 原文不保存在数据库;已有服务继续使用原配置。</p>${c.previous_valid_until ? `<p>旧 Token ${new Date(c.previous_valid_until) > new Date() ? '过渡截止' : '已失效于'}:${escape(time(c.previous_valid_until))}</p>` : ''}<div class="access-form-actions"><button class="text-button" data-access-expiry="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>调整有效期</button>${c.previous_valid_until && !c.revoked_at ? `<button class="text-button" data-access-finalize="${escape(c.id)}">结束过渡,停用旧 Token</button>` : ''}</div><div class="access-permissions">${groups.map(a => `<span>${escape(a)}</span>`).join('')}</div><details class="access-permission-details"><summary>查看具体权限</summary><p>${c.permissions.actions.map(escape).join(' · ')}<br>授权队列:${escape(c.permissions.queues.join(', '))}</p></details></section>`;
|
||||
}).join('') || '<p class="panel-description">该环境暂无凭据。</p>';
|
||||
document.querySelectorAll('[data-access-id]').forEach(el => el.classList.toggle('selected', el.dataset.accessId === namespace)); renderConfig();
|
||||
} catch (error) { $('access-error').textContent = message(error); }
|
||||
}
|
||||
function renderConfig() { try { $('access-config').textContent = config(); } catch (error) { $('access-config').textContent = error.message; } }
|
||||
function open(credential = null) {
|
||||
function open(credential = null, mode = 'ROTATE') {
|
||||
if (ui.busy) return;
|
||||
ui.rotating = credential; ui.pending = null; ui.result = null; ui.saved = true;
|
||||
ui.rotating = credential; ui.mode = credential ? mode : 'CREATE'; ui.pending = null; ui.result = null; ui.saved = true;
|
||||
$('access-form').reset(); $('access-form').querySelectorAll('input,select,button').forEach(el => { el.disabled = false; }); $('access-form').hidden = false; $('access-result').hidden = true; $('access-backup').hidden = true; $('access-form-error').textContent = '';
|
||||
$('access-result-config').value = ''; $('access-clipboard-status').textContent = '';
|
||||
$('access-create-fields').hidden = !!credential;
|
||||
$('access-create-fields').querySelectorAll('input,select').forEach(el => { el.disabled = !!credential; });
|
||||
$('access-form-title').textContent = credential ? '重新生成 Token' : '新建应用';
|
||||
$('access-form-note').textContent = credential ? `正在更新 ${ui.detail.entry.application} 的凭据。确认后旧 Token 立即失效,请同步更新业务服务配置;应用、队列、订阅和历史记录保持原有归属。` : '为应用创建独立的环境、队列与调用凭据。';
|
||||
$('access-submit').textContent = credential ? '确认重新生成 →' : '创建并生成凭据 →';
|
||||
const titles = { CREATE: '新建应用', ROTATE: '平滑换证', SET_EXPIRY: '调整有效期', FINALIZE: '结束过渡' };
|
||||
$('access-form-title').textContent = titles[ui.mode];
|
||||
$('access-valid-field').hidden = ui.mode === 'FINALIZE';
|
||||
$('access-grace-field').hidden = ui.mode !== 'ROTATE';
|
||||
$('access-form-note').textContent = {
|
||||
CREATE: '为应用创建独立的环境、队列与调用凭据,默认长期有效。',
|
||||
ROTATE: '生成新 Token 后,旧 Token 在过渡期内继续可用。逐台更新服务配置并正常收尾在途任务;确认全部切换后,再结束过渡。',
|
||||
SET_EXPIRY: '只调整当前 Token 的有效期,Token 和业务配置保持不变。已有旧 Token 的过渡截止不会延长。',
|
||||
FINALIZE: '此操作立即停用旧 Token,新 Token 继续有效。请先确认全部服务、Worker 和结果消费者已切换,在途执行已收尾。'
|
||||
}[ui.mode];
|
||||
$('access-submit').textContent = ui.mode === 'CREATE' ? '创建并生成凭据 →' : `确认${titles[ui.mode]} →`;
|
||||
$('access-dialog').showModal(); (credential ? $('access-reason') : $('access-name')).focus();
|
||||
}
|
||||
function close() {
|
||||
@@ -77,7 +85,8 @@
|
||||
const form = $('access-form'); const disabled = [...form.elements].map(el => [el, el.disabled]);
|
||||
try {
|
||||
if (!ui.pending) {
|
||||
const input = { kind: ui.rotating ? 'ROTATE' : 'CREATE', valid_days: Number($('access-valid-days').value), reason: $('access-reason').value.trim(), expected_revision: ui.rotating ? String(ui.rotating.revision) : '0' };
|
||||
const input = { kind: ui.mode, valid_days: ui.mode === 'FINALIZE' ? 0 : Number($('access-valid-days').value), reason: $('access-reason').value.trim(), expected_revision: ui.rotating ? String(ui.rotating.revision) : '0' };
|
||||
if (ui.mode === 'ROTATE') input.grace_days = Number($('access-grace-days').value);
|
||||
if (ui.rotating) Object.assign(input, { namespace_id: ui.selected, credential_id: ui.rotating.id });
|
||||
else Object.assign(input, { application: $('access-name').value.trim(), namespace: $('access-namespace').value.trim(), environment: $('access-environment').value, queue: $('access-queue-name').value.trim(), capabilities: [...document.querySelectorAll('.access-capabilities input:checked')].map(el => el.value) });
|
||||
form.querySelectorAll('input,select,button').forEach(el => { el.disabled = true; });
|
||||
@@ -86,7 +95,7 @@
|
||||
form.querySelectorAll('input,select,button').forEach(el => { el.disabled = true; });
|
||||
const op = ui.pending;
|
||||
await api('/apply', op);
|
||||
ui.saved = false;
|
||||
ui.saved = !op.token;
|
||||
let queue = op.queue_id || $('access-queue').value;
|
||||
// 先确认当前凭据版本,旧操作回执不能冒充仍有效的最新 Token。
|
||||
await select(op.namespace_id);
|
||||
@@ -94,12 +103,13 @@
|
||||
const expected = op.input.kind === 'CREATE' ? '1' : String(BigInt(op.input.expected_revision) + 1n);
|
||||
if (!credential || String(credential.revision) !== expected || credential.revoked_at) throw new Error('操作已提交,但凭据已被更新或暂时无法核对,请刷新应用详情;不要使用旧操作中的 Token。');
|
||||
if (!credential.permissions.queues.includes('*') && !credential.permissions.queues.includes(queue)) queue = ui.detail.queues.find(q => credential.permissions.queues.includes(q.id))?.id || '';
|
||||
const preparedConfig = config(op.token, queue, op.namespace_id);
|
||||
const preparedConfig = op.token ? config(op.token, queue, op.namespace_id) : '';
|
||||
ui.result = op; ui.pending = null;
|
||||
$('access-form').hidden = true; $('access-result').hidden = false;
|
||||
$('access-result-title').textContent = op.input.kind === 'CREATE' ? '应用已创建,可以接入' : '新 Token 已生效';
|
||||
$('access-result-title').textContent = { CREATE: '应用已创建,可以接入', ROTATE: '新 Token 已生效', SET_EXPIRY: '有效期已更新,原 Token 继续使用', FINALIZE: '过渡已结束,旧 Token 已停用' }[op.input.kind];
|
||||
$('access-result-note').hidden = !op.token; $('access-result-config-field').hidden = !op.token; $('access-result-actions').hidden = !op.token;
|
||||
$('access-result-config').value = preparedConfig;
|
||||
$('access-result-expiry').textContent = `有效期至 ${time(op.expires_at)}。业务服务读取这些配置后交给 SDK。`;
|
||||
$('access-result-expiry').textContent = `当前 Token:${time(credential.expires_at)}。${credential.previous_valid_until ? `旧 Token 截止:${time(credential.previous_valid_until)},请在此之前完成切换。` : '无旧 Token 过渡。'}`;
|
||||
} catch (error) {
|
||||
$('access-form-error').textContent = `${message(error)}${ui.pending ? '。保留当前窗口,点击“重试确认”会使用原操作核对结果。' : ''}`;
|
||||
} finally {
|
||||
@@ -110,7 +120,12 @@
|
||||
}
|
||||
$('access-url').value = location.origin;
|
||||
$('access-list').addEventListener('click', e => { const el = e.target.closest('[data-access-id]'); if (el) select(el.dataset.accessId); });
|
||||
$('access-credentials').addEventListener('click', e => { const el = e.target.closest('[data-access-rotate]'); const credential = ui.detail?.credentials.find(c => c.id === el?.dataset.accessRotate); if (credential && !credential.revoked_at) open(credential); });
|
||||
$('access-credentials').addEventListener('click', e => {
|
||||
const el = e.target.closest('[data-access-rotate],[data-access-expiry],[data-access-finalize]'); if (!el || el.disabled) return;
|
||||
const id = el.dataset.accessRotate || el.dataset.accessExpiry || el.dataset.accessFinalize;
|
||||
const credential = ui.detail?.credentials.find(c => c.id === id);
|
||||
if (credential && !credential.revoked_at) open(credential, el.dataset.accessExpiry ? 'SET_EXPIRY' : el.dataset.accessFinalize ? 'FINALIZE' : 'ROTATE');
|
||||
});
|
||||
$('access-create').addEventListener('click', () => open()); $('access-close').addEventListener('click', close);
|
||||
$('access-dialog').addEventListener('cancel', e => { e.preventDefault(); close(); });
|
||||
$('access-refresh').addEventListener('click', load); $('access-next').addEventListener('click', () => { ui.after = ui.next; load(); }); $('access-first').addEventListener('click', () => { ui.after = ''; load(); });
|
||||
|
||||
@@ -396,12 +396,13 @@
|
||||
<label>初始队列名称<input id="access-queue-name" value="default" maxlength="128" required pattern="[A-Za-z0-9][A-Za-z0-9_.:\-]*"></label>
|
||||
<fieldset class="access-capabilities"><legend>接入能力 <small>默认包含任务提交、查询与队列管理</small></legend><label><input type="checkbox" value="worker" checked> Worker 执行</label><label><input type="checkbox" value="events" checked> 结果订阅</label><label><input type="checkbox" value="schedules" checked> 定时调度</label><label><input type="checkbox" value="workflows" checked> 流程编排</label><label><input type="checkbox" value="recovery"> 人工恢复</label><label><input type="checkbox" value="resources"> 应用额度管理</label></fieldset>
|
||||
</div>
|
||||
<div class="access-config-grid"><label>凭据有效期<select id="access-valid-days"><option value="30">30 天</option><option value="90">90 天</option><option value="365">365 天</option></select></label><label>操作原因<input id="access-reason" maxlength="1024" required placeholder="例如:接入订单服务"></label></div>
|
||||
<div class="access-config-grid"><label id="access-valid-field">凭据有效期<select id="access-valid-days"><option value="0">长期有效</option><option value="30">30 天</option><option value="90">90 天</option><option value="365">365 天</option></select></label><label>操作原因<input id="access-reason" maxlength="1024" required placeholder="例如:接入订单服务"></label></div>
|
||||
<label id="access-grace-field" hidden>旧 Token 过渡期<select id="access-grace-days"><option value="7">7 天</option><option value="1">1 天</option><option value="30">30 天</option></select><small>新旧 Token 同时可用。请在截止前完成所有服务的配置更新;旧 Token 原到期时间更早时,以原到期时间为准。</small></label>
|
||||
<p class="action-error" id="access-form-error" role="alert"></p>
|
||||
<div class="access-form-actions"><button type="submit" class="primary-button compact" id="access-submit">创建并生成凭据 →</button><button type="button" class="text-button" id="access-backup" hidden>保存待核对操作</button></div>
|
||||
</form>
|
||||
<p id="access-clipboard-status" class="panel-description" role="status"></p>
|
||||
<section id="access-result" hidden><div class="access-success">✓ <span id="access-result-title">应用已创建</span></div><p class="panel-description">完整 Token 仅在本次操作中显示。请复制或下载到服务配置中;页面关闭后只能重新生成。</p><label>完整 SDK 配置<textarea id="access-result-config" readonly rows="7" spellcheck="false"></textarea></label><p class="panel-description" id="access-result-expiry"></p><div class="access-form-actions"><button type="button" class="primary-button compact" id="access-copy">复制配置 ↗</button><button type="button" class="secondary-button compact" id="access-download">下载 .env ↓</button></div></section>
|
||||
<section id="access-result" hidden><div class="access-success">✓ <span id="access-result-title">应用已创建</span></div><p class="panel-description" id="access-result-note">完整 Token 仅在本次操作中显示。请复制或下载到服务配置中;页面关闭后只能重新生成。</p><label id="access-result-config-field">完整 SDK 配置<textarea id="access-result-config" readonly rows="7" spellcheck="false"></textarea></label><p class="panel-description" id="access-result-expiry"></p><div class="access-form-actions" id="access-result-actions"><button type="button" class="primary-button compact" id="access-copy">复制配置 ↗</button><button type="button" class="secondary-button compact" id="access-download">下载 .env ↓</button></div></section>
|
||||
</dialog>
|
||||
|
||||
<script src="/console/applications.js"></script>
|
||||
|
||||
@@ -91,8 +91,8 @@ func TestConsolePreparedApplicationRoundTrip(t *testing.T) {
|
||||
queue, _ := core.NewID()
|
||||
opID, _ := core.NewID()
|
||||
token, _, _ := auth.Issue(ns, cred)
|
||||
for _, kind := range []string{"CREATE", "ROTATE"} {
|
||||
op := consoleauth.ApplicationOperation{Input: consoleauth.ApplicationInput{Kind: kind, ValidDays: 30, Reason: "test"}, OperationID: opID, ApplicationID: app, NamespaceID: ns, CredentialID: cred, Token: token, ExpiresAt: time.Now().UTC()}
|
||||
for _, kind := range []string{"CREATE", "ROTATE", "SET_EXPIRY", "FINALIZE"} {
|
||||
op := consoleauth.ApplicationOperation{Input: consoleauth.ApplicationInput{Kind: kind, ValidDays: 30, Reason: "test"}, OperationID: opID, ApplicationID: app, NamespaceID: ns, CredentialID: cred, Token: token, ExpiresAt: func() *time.Time { v := time.Now().UTC(); return &v }()}
|
||||
if kind == "CREATE" {
|
||||
op.QueueID = queue
|
||||
op.Input.Application = "sample"
|
||||
@@ -104,6 +104,12 @@ func TestConsolePreparedApplicationRoundTrip(t *testing.T) {
|
||||
op.Input.CredentialID = cred
|
||||
op.Input.ExpectedRevision = 1
|
||||
}
|
||||
if kind == "SET_EXPIRY" || kind == "FINALIZE" {
|
||||
op.Version = 2
|
||||
op.Token = ""
|
||||
op.ExpiresAt = nil
|
||||
op.Input.ValidDays = 0
|
||||
}
|
||||
raw, err := json.Marshal(op)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -114,7 +120,7 @@ func TestConsolePreparedApplicationRoundTrip(t *testing.T) {
|
||||
if err := decodeBody(req, &got); err != nil {
|
||||
t.Fatalf("%s prepared output rejected: %v", kind, err)
|
||||
}
|
||||
if got.Token != token || got.Input.Kind != kind {
|
||||
if got.Token != op.Token || got.Input.Kind != kind {
|
||||
t.Fatal("prepared operation changed")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user