package mysql import ( "context" "crypto/sha256" "database/sql" "encoding/json" "fmt" "strings" "time" "unicode/utf8" "git.yuntuo.hk/k2safe/orbit/internal/fault" core "git.yuntuo.hk/k2safe/orbit/model" "git.yuntuo.hk/k2safe/orbit/platform/auth" ) const ( credentialOperationVersion = 1 credentialRotateAction = "ROTATE" credentialRevokeAction = "REVOKE" credentialOperationName = "credential.change.v1" ) // CredentialStatus 是管理读取的脱敏凭据状态;永远不包含秘密或摘要。 // Enabled 字段分别表示所属命名空间和应用当前是否允许业务认证。 type CredentialStatus struct { // NamespaceID 凭据所属命名空间标识。 NamespaceID core.ID `json:"namespace_id"` // CredentialID 凭据记录标识,轮换时保持不变。 CredentialID core.ID `json:"credential_id"` // Name 凭据的管理名称。 Name string `json:"name"` // Revision 凭据的乐观并发版本,所有变更都必须核对它。 Revision uint64 `json:"revision"` // ExpiresAt 当前秘密的到期时间;空值表示未设置到期时间。 ExpiresAt *time.Time `json:"expires_at,omitempty"` // RevokedAt 撤销时间;空值表示尚未撤销。 RevokedAt *time.Time `json:"revoked_at,omitempty"` // NamespaceEnabled 命名空间是否启用。 NamespaceEnabled bool `json:"namespace_enabled"` // ApplicationEnabled 所属应用是否启用。 ApplicationEnabled bool `json:"application_enabled"` } // CredentialChangeInput 描述一次待固定的管理变更。 // ExpectedRevision 防止准备和提交之间的并发变更被覆盖。 type CredentialChangeInput struct { // NamespaceID 凭据所属命名空间。 NamespaceID core.ID // CredentialID 要变更的凭据。 CredentialID core.ID // ExpectedRevision 准备操作时观察到的版本。 ExpectedRevision uint64 // Action 只能是 ROTATE 或 REVOKE。 Action string // ValidFor 轮换后的有效时长;撤销时必须为零。 ValidFor time.Duration // Reason 必填审计原因。 Reason string } // CredentialOperation 是可安全重放的私有操作文件内容。 // 令牌只存在于 ROTATE 文件中;服务端只保存其摘要和操作回执。 type CredentialOperation struct { // Version 操作文件格式版本,升级时不得静默改变语义。 Version int `json:"version"` // OperationID 稳定操作身份,同时作为审计和幂等记录的身份。 OperationID core.ID `json:"operation_id"` // NamespaceID 凭据所属命名空间。 NamespaceID core.ID `json:"namespace_id"` // CredentialID 变更目标凭据。 CredentialID core.ID `json:"credential_id"` // ExpectedRevision 只允许在该版本上首次应用。 ExpectedRevision uint64 `json:"expected_revision"` // Action 固定为 ROTATE 或 REVOKE。 Action string `json:"action"` // Reason 写入管理审计,不能包含控制字符或秘密。 Reason string `json:"reason"` // Token 轮换产生的一次性新令牌;撤销操作为空。 Token string `json:"token,omitempty"` // ExpiresAt 轮换后固定的绝对到期时间;重放不能重新计算。 ExpiresAt *time.Time `json:"expires_at,omitempty"` } // CredentialChangeReceipt 是服务端保存并可重复返回的脱敏结果。 // Deduplicated 只表示本次调用命中了已有回执,不表示当前凭据仍处于该状态。 type CredentialChangeReceipt struct { // OperationID 稳定操作身份。 OperationID core.ID `json:"operation_id"` // NamespaceID 操作所属命名空间。 NamespaceID core.ID `json:"namespace_id"` // CredentialID 变更目标凭据。 CredentialID core.ID `json:"credential_id"` // Action 已应用的变更类型。 Action string `json:"action"` // Revision 该操作提交后凭据版本。 Revision uint64 `json:"revision"` // AppliedAt 操作提交事务使用的数据库时间。 AppliedAt time.Time `json:"applied_at"` // ExpiresAt 轮换后的固定到期时间。 ExpiresAt *time.Time `json:"expires_at,omitempty"` // RevokedAt 撤销提交时间。 RevokedAt *time.Time `json:"revoked_at,omitempty"` // Deduplicated 当前调用是否读取了既有回执。 Deduplicated bool `json:"deduplicated"` } // GetCredentialStatus 读取脱敏状态,不需要使用该凭据认证。 func GetCredentialStatus(ctx context.Context, db *sql.DB, namespace, credential core.ID) (CredentialStatus, error) { var out CredentialStatus if err := validateCredentialIDs(namespace, credential); err != nil { return out, err } 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 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) if err == sql.ErrNoRows { return out, fault.NotFound } if err != nil { return out, err } out.NamespaceID, out.CredentialID = namespace, credential out.ExpiresAt = nullableTime(expires) out.RevokedAt = nullableTime(revoked) return out, nil } // PrepareCredentialOperation 只读取当前状态并生成一次秘密,不修改数据库。 // 调用方应先把返回值写入受保护的新文件,再调用 ApplyCredentialOperation。 func PrepareCredentialOperation(ctx context.Context, db *sql.DB, in CredentialChangeInput) (CredentialOperation, error) { var out CredentialOperation if err := validateCredentialChangeInput(in); err != nil { return out, err } if err := CheckSchema(ctx, db); err != nil { return out, err } status, err := GetCredentialStatus(ctx, db, in.NamespaceID, in.CredentialID) if err != nil { return out, err } if status.Revision != in.ExpectedRevision || status.RevokedAt != nil { return out, fault.Conflict } var now time.Time if err := db.QueryRowContext(ctx, "SELECT UTC_TIMESTAMP(6)").Scan(&now); err != nil { return out, err } now = now.UTC() if in.Action == credentialRotateAction { secret, _, err := auth.Issue(in.NamespaceID, in.CredentialID) if err != nil { return out, err } out.Token = secret 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 out.CredentialID = in.CredentialID out.ExpectedRevision = in.ExpectedRevision out.Action = in.Action out.Reason = in.Reason out.OperationID, err = core.NewID() if err != nil { return out, err } return out, ValidateCredentialOperation(out) } // 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 { return fault.Invalid("credential operation identity or version is invalid") } if op.ExpectedRevision == 0 || (op.Action != credentialRotateAction && op.Action != credentialRevokeAction) { return fault.Invalid("credential operation action or revision is invalid") } if err := validCredentialReason(op.Reason); err != nil { return err } 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") } return nil } // ApplyCredentialOperation applies one frozen operation or returns its original // receipt. No callback or file I/O occurs inside the transaction. A commit error // is intentionally returned unchanged; replaying the same operation reconciles it. func ApplyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOperation) (CredentialChangeReceipt, error) { var out CredentialChangeReceipt if err := ValidateCredentialOperation(op); err != nil { return out, err } if err := CheckSchema(ctx, db); err != nil { return out, err } err := WithTx(ctx, db, func(tx *sql.Tx) error { var name string var revision uint64 var expires, revoked 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 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) if err == sql.ErrNoRows { return fault.NotFound } if err != nil { return err } identity, cached, err := reserveCredentialOperation(ctx, tx, op) if err != nil { return err } if cached != nil { if err := json.Unmarshal(cached, &out); err != nil { return fmt.Errorf("stored credential receipt is invalid: %w", err) } out.Deduplicated = true return nil } if revision != op.ExpectedRevision || revoked.Valid { return fault.Conflict } if revision == ^uint64(0) { return fault.Conflict } now, err := dbNow(ctx, tx) 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} 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)) { return fault.Conflict } 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 { 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 { return err } out.RevokedAt = timePtr(now) after["revoked_at"] = now } out.OperationID = op.OperationID out.NamespaceID = op.NamespaceID out.CredentialID = op.CredentialID out.Action = op.Action out.Revision = revision + 1 out.AppliedAt = now actor, err := databaseActor(ctx, tx) if err != nil { return err } if err := appendCredentialAudit(ctx, tx, actor, op, before, after); err != nil { return err } encoded, err := json.Marshal(out) if err != nil { return err } if _, err := tx.ExecContext(ctx, `UPDATE orbit_idempotency_keys SET response=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=?`, encoded, now, op.NamespaceID, identity); err != nil { return err } return nil }) return out, err } // reserveCredentialOperation stores the frozen request digest before mutation. // The operation ID is also the idempotency row ID, so retries never mint a new // secret or a second audit event. func reserveCredentialOperation(ctx context.Context, tx *sql.Tx, op CredentialOperation) (core.ID, []byte, error) { digest, err := requestDigest(op) if err != nil { return "", nil, err } keyDigest := core.Digest(sha256.Sum256([]byte(op.OperationID))) _, err = tx.ExecContext(ctx, `INSERT INTO orbit_idempotency_keys(namespace_id,id,operation,scope_key,key_digest,request_digest,resource_kind,resource_id,response,expires_at) VALUES (?,?,?,?,?,?,?,?,JSON_OBJECT(),DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 90 DAY)) ON DUPLICATE KEY UPDATE id=orbit_idempotency_keys.id`, op.NamespaceID, op.OperationID, credentialOperationName, string(op.CredentialID), keyDigest, digest, "credential", op.CredentialID) if err != nil { return "", nil, err } var storedID core.ID var storedDigest core.Digest var response []byte if err := tx.QueryRowContext(ctx, `SELECT id,request_digest,response FROM orbit_idempotency_keys WHERE namespace_id=? AND operation=? AND scope_key=? AND key_digest=? FOR UPDATE`, op.NamespaceID, credentialOperationName, string(op.CredentialID), keyDigest). Scan(&storedID, &storedDigest, &response); err != nil { return "", nil, err } if subtleCompareDigest(digest, storedDigest) != 1 { return "", nil, fault.IdempotencyConflict } trimmed := strings.TrimSpace(string(response)) if trimmed != "" && trimmed != "{}" && trimmed != "null" { return storedID, response, nil } return storedID, nil, nil } func appendCredentialAudit(ctx context.Context, tx *sql.Tx, actor string, op CredentialOperation, before, after any) error { return appendAuditActor(ctx, tx, op.NamespaceID, actor, op.OperationID, strings.ToLower("credential."+strings.ToLower(op.Action)), op.CredentialID, op.Reason, before, after) } func appendAuditActor(ctx context.Context, tx *sql.Tx, namespace core.ID, actor string, operation core.ID, action string, resource core.ID, reason string, before, after any) error { old, err := json.Marshal(before) if err != nil { return err } current, err := json.Marshal(after) if err != nil { return err } id, err := core.NewID() if err != nil { return err } _, err = tx.ExecContext(ctx, `INSERT INTO orbit_audit_events(namespace_id,id,operation_id,actor_id,action,resource_kind,resource_id,reason,evidence_ref,`+"`before`"+`,`+"`after`"+`) VALUES (?,?,?,?,?,?,?,?,NULL,?,?)`, namespace, id, operation, actor, action, "credential", resource, reason, string(old), string(current)) return err } func databaseActor(ctx context.Context, tx *sql.Tx) (string, error) { var principal string if err := tx.QueryRowContext(ctx, "SELECT CURRENT_USER()").Scan(&principal); err != nil { return "", err } principal = "mysql:" + principal if len(principal) <= 128 { return principal, nil } sum := sha256.Sum256([]byte(principal)) return fmt.Sprintf("mysql-sha256:%x", sum[:]), nil } func subtleCompareDigest(a, b core.Digest) int { var result byte for i := range a { result |= a[i] ^ b[i] } if result == 0 { return 1 } return 0 } func validateCredentialIDs(namespace, credential core.ID) error { if namespace.Validate() != nil || credential.Validate() != nil { return fault.Invalid("credential identity is invalid") } return nil } 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) { 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.Action == credentialRevokeAction && in.ValidFor != 0 { return fault.Invalid("revoke operation cannot set validity") } return nil } func validCredentialReason(reason string) error { if strings.TrimSpace(reason) == "" || !utf8.ValidString(reason) || len([]rune(reason)) > 1024 || strings.ContainsAny(reason, "\x00\r\n") { return fault.Invalid("credential reason is required and must not contain control newlines") } return nil } func nullableTime(v sql.NullTime) *time.Time { if !v.Valid { return nil } t := v.Time.UTC() return &t } func timePtr(v time.Time) *time.Time { t := v.UTC() return &t }