feat: support permanent credentials and overlapping token rotation
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user