feat: make credential changes replayable

This commit is contained in:
2026-09-18 15:29:05 +08:00
parent a7be32f661
commit bea8efa790
12 changed files with 1034 additions and 8 deletions

View File

@@ -84,3 +84,4 @@
- P6进程观测使用internal/observability固定指标目录,拒绝未知名称、类型和动态标签,不能引入任务/租户/业务键/凭据维度。ORBIT_METRICS_ADDR默认空(关闭),显式启用独立采集监听;业务端口不得暴露全局指标。渲染和网络I/O不持有全局记录锁,每个直方图局部快照保持桶/count/sum一致,最多2个并发抓取。 - P6进程观测使用internal/observability固定指标目录,拒绝未知名称、类型和动态标签,不能引入任务/租户/业务键/凭据维度。ORBIT_METRICS_ADDR默认空(关闭),显式启用独立采集监听;业务端口不得暴露全局指标。渲染和网络I/O不持有全局记录锁,每个直方图局部快照保持桶/count/sum一致,最多2个并发抓取。
- HTTP成功升级在Hijack完成时记录握手,WS处理器存续计入独立连接指标;保持1xx临时响应、Flush、Hijack与原panic行为。DB池取本地Stats,抓取不查询业务库;后台批次success不是业务任务成功量。观测丢样或失败不得改变权威事务及恢复决定。 - HTTP成功升级在Hijack完成时记录握手,WS处理器存续计入独立连接指标;保持1xx临时响应、Flush、Hijack与原panic行为。DB池取本地Stats,抓取不查询业务库;后台批次success不是业务任务成功量。观测丢样或失败不得改变权威事务及恢复决定。
- 进程指标、格式验证、告警规则和标准回归证据见docs/reports/phase-6-observability.md。仍须补受理/授权/完成与流程推进延迟、持久吞吐/积压、重试/核查/恢复量、发送队列、实际采集告警及负载开销,不能把进程观测通过写成P6已完成。普通清理仍须保护有效幂等、未确认事件、未知结果和未终结流程。 - 进程指标、格式验证、告警规则和标准回归证据见docs/reports/phase-6-observability.md。仍须补受理/授权/完成与流程推进延迟、持久吞吐/积压、重试/核查/恢复量、发送队列、实际采集告警及负载开销,不能把进程观测通过写成P6已完成。普通清理仍须保护有效幂等、未确认事件、未知结果和未终结流程。
- 凭据轮换保留credential_id及订阅/Worker授权绑定,在同一事务替换摘要、固定到期时间、幂等回执和管理审计;新令牌必须重新建立会话,认证路径每次重新核对凭据状态。撤销必须让认证失效并保留原任务、订阅、交付、会话和审计证据,允许在应用或命名空间停用后执行;重复应用同一固定操作幂等返回。管理CLI先把固定操作文件写成0600,再提交;提交不明时保留文件并用原文件重放,不能重新生成秘密。日志、审计摘要、指标和业务表不得保存秘密原文或摘要。不要把轮换同一凭据ID解释成历史令牌仍有效,也不得级联删除订阅、任务或事件。

View File

@@ -0,0 +1,223 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"errors"
"flag"
"io"
"os"
"path/filepath"
"strings"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/config"
"git.yuntuo.hk/k2safe/orbit/internal/storage/mysql"
core "git.yuntuo.hk/k2safe/orbit/model"
)
func runCredential(args []string) error {
if len(args) == 0 {
return errors.New("usage: orbit-admin credential status|rotate|revoke|apply")
}
switch args[0] {
case "status":
return runCredentialStatus(args[1:])
case "rotate":
return runCredentialChange(args[1:], "ROTATE")
case "revoke":
return runCredentialChange(args[1:], "REVOKE")
case "apply":
return runCredentialApply(args[1:])
default:
return errors.New("usage: orbit-admin credential status|rotate|revoke|apply")
}
}
func runCredentialStatus(args []string) error {
flags := flag.NewFlagSet("credential status", flag.ContinueOnError)
namespace, credential := "", ""
flags.StringVar(&namespace, "namespace-id", "", "credential namespace id")
flags.StringVar(&credential, "credential-id", "", "credential id")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 {
return errors.New("credential status does not accept positional arguments")
}
ns, id, err := credentialIDs(namespace, credential)
if err != nil {
return err
}
ctx, db, closeDB, err := adminDB(15 * time.Second)
if err != nil {
return err
}
defer closeDB()
status, err := mysql.GetCredentialStatus(ctx, db, ns, id)
if err != nil {
return errors.New("credential status failed")
}
return json.NewEncoder(os.Stdout).Encode(status)
}
func runCredentialChange(args []string, action string) error {
flags := flag.NewFlagSet("credential "+strings.ToLower(action), flag.ContinueOnError)
namespace, credential, output, reason, validFor := "", "", "", "", "720h"
var expected uint64
flags.StringVar(&namespace, "namespace-id", "", "credential namespace id")
flags.StringVar(&credential, "credential-id", "", "credential id")
flags.Uint64Var(&expected, "expected-revision", 0, "current credential revision required for first apply")
flags.StringVar(&reason, "reason", "", "required audit reason")
flags.StringVar(&output, "output", "", "new private operation file; an existing file is never overwritten")
flags.StringVar(&validFor, "valid-for", "720h", "new credential validity, from 1h to 8760h")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 || output == "" {
return errors.New("credential change requires --namespace-id, --credential-id, --expected-revision, --reason and --output")
}
ns, id, err := credentialIDs(namespace, credential)
if err != nil || expected == 0 {
return errors.New("credential change requires valid ids and a nonzero --expected-revision")
}
duration := time.Duration(0)
if action == "ROTATE" {
duration, err = time.ParseDuration(validFor)
if err != nil {
return errors.New("invalid --valid-for duration")
}
}
ctx, db, closeDB, err := adminDB(30 * time.Second)
if err != nil {
return err
}
defer closeDB()
op, err := mysql.PrepareCredentialOperation(ctx, db, mysql.CredentialChangeInput{
NamespaceID: ns, CredentialID: id, ExpectedRevision: expected, Action: action, ValidFor: duration, Reason: reason,
})
if err != nil {
return errors.New("credential operation could not be prepared")
}
if err := writePrivateOperation(output, op); err != nil {
return err
}
receipt, err := mysql.ApplyCredentialOperation(ctx, db, op)
if err != nil {
return errors.New("credential operation was not confirmed; the private operation file was retained for reconciliation")
}
return json.NewEncoder(os.Stdout).Encode(receipt)
}
func runCredentialApply(args []string) error {
flags := flag.NewFlagSet("credential apply", flag.ContinueOnError)
file := ""
flags.StringVar(&file, "file", "", "existing private credential operation file")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 || file == "" {
return errors.New("credential apply requires --file")
}
op, err := readPrivateOperation(file)
if err != nil {
return err
}
ctx, db, closeDB, err := adminDB(30 * time.Second)
if err != nil {
return err
}
defer closeDB()
receipt, err := mysql.ApplyCredentialOperation(ctx, db, op)
if err != nil {
return errors.New("credential operation was not confirmed; retry with the same private operation file")
}
return json.NewEncoder(os.Stdout).Encode(receipt)
}
func credentialIDs(namespace, credential string) (core.ID, core.ID, error) {
ns, id := core.ID(namespace), core.ID(credential)
if ns.Validate() != nil || id.Validate() != nil {
return "", "", errors.New("valid --namespace-id and --credential-id are required")
}
return ns, id, nil
}
func adminDB(timeout time.Duration) (context.Context, *sql.DB, func(), error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
cfg, err := config.Load()
if err != nil {
cancel()
return nil, nil, func() {}, err
}
db, err := mysql.Open(ctx, cfg.MySQLDSN, 2, 1)
if err != nil {
cancel()
return nil, nil, func() {}, err
}
return ctx, db, func() { db.Close(); cancel() }, nil
}
func writePrivateOperation(path string, op mysql.CredentialOperation) error {
if path == "" || filepath.Base(path) == "." || filepath.Base(path) == string(filepath.Separator) {
return errors.New("invalid private operation path")
}
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return errors.New("cannot create private operation file; existing files are never overwritten")
}
keep := false
defer func() {
_ = file.Close()
if !keep {
_ = os.Remove(path)
}
}()
if err := file.Chmod(0600); err != nil {
return errors.New("cannot protect private operation file")
}
if err := json.NewEncoder(file).Encode(op); err != nil {
return errors.New("cannot write private operation file")
}
if err := file.Sync(); err != nil {
return errors.New("cannot sync private operation file")
}
directory, err := os.Open(filepath.Dir(path))
if err != nil {
return errors.New("cannot open private operation directory")
}
err = directory.Sync()
closeErr := directory.Close()
if err != nil || closeErr != nil {
return errors.New("cannot sync private operation directory")
}
keep = true
return nil
}
func readPrivateOperation(path string) (mysql.CredentialOperation, error) {
var op mysql.CredentialOperation
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || info.Size() > 64*1024 {
return op, errors.New("private operation file must be a regular 0600 file no larger than 64 KiB")
}
file, err := os.Open(path)
if err != nil {
return op, errors.New("cannot read private operation file")
}
defer file.Close()
decoder := json.NewDecoder(io.LimitReader(file, 64*1024+1))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&op); err != nil {
return op, errors.New("private operation file is invalid")
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
return op, errors.New("private operation file must contain exactly one JSON object")
}
if err := mysql.ValidateCredentialOperation(op); err != nil {
return op, errors.New("private operation file failed validation")
}
return op, nil
}

View File

@@ -0,0 +1,86 @@
package main
import (
"os"
"path/filepath"
"testing"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/storage/mysql"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
func testOperation(t *testing.T) mysql.CredentialOperation {
t.Helper()
ns, err := core.NewID()
if err != nil {
t.Fatal(err)
}
id, err := core.NewID()
if err != nil {
t.Fatal(err)
}
opID, err := core.NewID()
if err != nil {
t.Fatal(err)
}
token, _, err := auth.Issue(ns, id)
if err != nil {
t.Fatal(err)
}
expires := time.Now().UTC().Add(time.Hour)
return mysql.CredentialOperation{Version: 1, OperationID: opID, NamespaceID: ns, CredentialID: id, ExpectedRevision: 1, Action: "ROTATE", Reason: "unit test", Token: token, ExpiresAt: &expires}
}
func TestPrivateOperationFileIsExclusiveAnd0600(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "operation.json")
op := testOperation(t)
if err := writePrivateOperation(path, op); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0600 {
t.Fatalf("private file mode=%v", info.Mode().Perm())
}
read, err := readPrivateOperation(path)
if err != nil || read.OperationID != op.OperationID || read.Token != op.Token {
t.Fatalf("private operation round trip: %+v %v", read, err)
}
if err := writePrivateOperation(path, op); err == nil {
t.Fatal("existing private operation was overwritten")
}
if err := os.Chmod(path, 0640); err != nil {
t.Fatal(err)
}
if _, err := readPrivateOperation(path); err == nil {
t.Fatal("group-readable operation was accepted")
}
}
func TestPrivateOperationFileRejectsUnknownFieldsAndSymlinks(t *testing.T) {
dir := t.TempDir()
unknown := filepath.Join(dir, "unknown.json")
if err := os.WriteFile(unknown, []byte(`{"version":1,"unexpected":true}`), 0600); err != nil {
t.Fatal(err)
}
if _, err := readPrivateOperation(unknown); err == nil {
t.Fatal("unknown operation field was accepted")
}
op := testOperation(t)
target := filepath.Join(dir, "target.json")
if err := writePrivateOperation(target, op); err != nil {
t.Fatal(err)
}
link := filepath.Join(dir, "link.json")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
if _, err := readPrivateOperation(link); err == nil {
t.Fatal("symlink operation file was accepted")
}
}

View File

@@ -22,9 +22,19 @@ func main() {
} }
} }
func run() error { func run() error {
if len(os.Args) < 2 || os.Args[1] != "bootstrap" { if len(os.Args) < 2 {
return errors.New("usage: orbit-admin bootstrap (--application NAME | --application-id ID) --namespace NAME --environment NAME --queue NAME --output NEW_FILE") return errors.New("usage: orbit-admin bootstrap|credential status|rotate|revoke|apply")
} }
switch os.Args[1] {
case "bootstrap":
return runBootstrap(os.Args[2:])
case "credential":
return runCredential(os.Args[2:])
}
return errors.New("usage: orbit-admin bootstrap|credential status|rotate|revoke|apply")
}
func runBootstrap(args []string) error {
flags := flag.NewFlagSet("bootstrap", flag.ContinueOnError) flags := flag.NewFlagSet("bootstrap", flag.ContinueOnError)
var in mysql.BootstrapInput var in mysql.BootstrapInput
var output string var output string
@@ -48,7 +58,7 @@ func run() error {
flags.BoolVar(&in.WorkflowSignalAccess, "workflow-signal-access", false, "explicitly grant workflow signal and read actions") flags.BoolVar(&in.WorkflowSignalAccess, "workflow-signal-access", false, "explicitly grant workflow signal and read actions")
flags.BoolVar(&in.WorkflowRecoveryAccess, "workflow-recovery-access", false, "explicitly grant workflow recovery and read actions") flags.BoolVar(&in.WorkflowRecoveryAccess, "workflow-recovery-access", false, "explicitly grant workflow recovery and read actions")
flags.BoolVar(&in.WorkflowAuditAccess, "workflow-audit-access", false, "explicitly grant workflow operation audit read action") flags.BoolVar(&in.WorkflowAuditAccess, "workflow-audit-access", false, "explicitly grant workflow operation audit read action")
if err := flags.Parse(os.Args[2:]); err != nil { if err := flags.Parse(args); err != nil {
return err return err
} }
if flags.NArg() != 0 || output == "" { if flags.NArg() != 0 || output == "" {

View File

@@ -60,7 +60,7 @@ P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](
1. 已补[首批可复现故障工具](reports/phase-6-fault-boundaries.md):A04“派发已提交但消息尚未送达”、W01/W13事务提交结果不明,以及W16最终回执/旧PENDING结果重放/下一轮竞争。每个切点必须有实际数据库或进程证据,不能用响应错误推断事务是否已提交,也不能用普通SIGNAL竞争替代最终查询场景。 1. 已补[首批可复现故障工具](reports/phase-6-fault-boundaries.md):A04“派发已提交但消息尚未送达”、W01/W13事务提交结果不明,以及W16最终回执/旧PENDING结果重放/下一轮竞争。每个切点必须有实际数据库或进程证据,不能用响应错误推断事务是否已提交,也不能用普通SIGNAL竞争替代最终查询场景。
2. 已实现[基础运行观测](api/observability.md):HTTP/WS、数据库连接池、后台批次和进程指标,以及可离线验证的告警规则;独立采集监听默认关闭,固定指标目录拒绝动态标签。仍需受理/授权/完成阶段延迟与持久吞吐、流程推进延迟、重试/核查/恢复量、待处理与到期积压、发送队列及生产采集/通知验证。 2. 已实现[基础运行观测](api/observability.md):HTTP/WS、数据库连接池、后台批次和进程指标,以及可离线验证的告警规则;独立采集监听默认关闭,固定指标目录拒绝动态标签。仍需受理/授权/完成阶段延迟与持久吞吐、流程推进延迟、重试/核查/恢复量、待处理与到期积压、发送队列及生产采集/通知验证。
3. 补齐凭据签发、轮换、撤销及Worker/订阅安全重绑,保持最小权限、管理审计与未知回执可恢复。 3. 已补齐受保护管理环境中的固定凭据操作文件、轮换、撤销、幂等回执和脱敏管理审计;轮换保留授权绑定并要求新令牌重新建立会话,撤销允许在停用范围内执行且不改写历史记录。仍需补齐提交不明故障切点、多节点安全重绑演练、凭据最小权限配置核对和生产密钥托管/审计演练。
4. 实现保留/归档与受保护记录核对,再执行备份恢复、兼容升级和六类容量负载。未确认交付、有效幂等身份、当前/历史执行证据和未终结流程不能被普通清理破坏。生产HA及RPO/RTO继续按实际拓扑验证,不缩减为本机多进程检查。 4. 实现保留/归档与受保护记录核对,再执行备份恢复、兼容升级和六类容量负载。未确认交付、有效幂等身份、当前/历史执行证据和未终结流程不能被普通清理破坏。生产HA及RPO/RTO继续按实际拓扑验证,不缩减为本机多进程检查。
### P3 功能范围核对 ### P3 功能范围核对

View File

@@ -2,7 +2,7 @@
- 文档版本:0.2 - 文档版本:0.2
- 整理日期:2026-09-17 - 整理日期:2026-09-17
- 阶段:P0–P5平台功能已通过阶段回归;[P4/P5原范围核对](reports/phase-4-5.md)和[首批故障边界](reports/phase-6-fault-boundaries.md)保留证据边界。P6生产观测与运维、P7首版交付继续推进,尚未完成全平台生产验收,数据库保持无外键。 - 阶段:P0–P5平台功能已通过阶段回归;[P4/P5原范围核对](reports/phase-4-5.md)和[首批故障边界](reports/phase-6-fault-boundaries.md)保留证据边界。P6已补进程观测和固定操作文件凭据轮换/撤销;生产积压、阶段延迟、提交不明切点、归档备份、容量与发布交付继续推进,尚未完成全平台生产验收,数据库保持无外键。
- 适用范围:Orbit 统一平台,包含 Pulse 任务调度模块、Weave 流程编排模块与公共能力。平台面向多个业务系统复用。 - 适用范围:Orbit 统一平台,包含 Pulse 任务调度模块、Weave 流程编排模块与公共能力。平台面向多个业务系统复用。
## 阅读顺序 ## 阅读顺序

View File

@@ -19,7 +19,7 @@
| `/v1/application/resources` 查询及 `:configure` 管理 | P3 已实现跨命名空间应用配额 | [应用配额与 SDK 场景](application-resources.md) | | `/v1/application/resources` 查询及 `:configure` 管理 | P3 已实现跨命名空间应用配额 | [应用配额与 SDK 场景](application-resources.md) |
| 任务 `concurrency_key` 与尝试 `concurrency_token` | P3 已实现跨队列资源键互斥 | [资源键接口与 SDK 场景](keyed-concurrency.md) | | 任务 `concurrency_key` 与尝试 `concurrency_token` | P3 已实现跨队列资源键互斥 | [资源键接口与 SDK 场景](keyed-concurrency.md) |
| 任务 `priority` 与节点领取池 | P3 已实现有界优先级加速、应用/队列轮转 | [优先级与公平派发](priority-and-fairness.md) | | 任务 `priority` 与节点领取池 | P3 已实现有界优先级加速、应用/队列轮转 | [优先级与公平派发](priority-and-fairness.md) |
| 凭据校验与初始化 CLI | P1 已实现 | [鉴权](authentication.md) | | 凭据校验、初始化、固定操作文件轮换/撤销与回执重放 CLI | P1/P6 已实现;不提供公网管理路由,测试专用提交不明切点已回归 | [鉴权](authentication.md)、[凭据报告](../reports/phase-6-credentials.md) |
| `/v1/worker/connect` | P2 已实现并通过阶段验收 | [Worker 契约](worker.md)、[Schema](worker-schema.md) | | `/v1/worker/connect` | P2 已实现并通过阶段验收 | [Worker 契约](worker.md)、[Schema](worker-schema.md) |
| 任务相对延迟 `delay_ms` | 已实现数据库计时、原键恢复与实际执行例子 | [任务接口](tasks.md)、[延迟示例](../examples/delayed-task.md) | | 任务相对延迟 `delay_ms` | 已实现数据库计时、原键恢复与实际执行例子 | [任务接口](tasks.md)、[延迟示例](../examples/delayed-task.md) |
| 任务冻结策略与 Worker `retryable` | P3 安全重试已实现 | [安全重试](retries.md) | | 任务冻结策略与 Worker `retryable` | P3 安全重试已实现 | [安全重试](retries.md) |

View File

@@ -23,7 +23,53 @@ ORBIT_MYSQL_DSN 通过环境注入,命令不读取 .env。所有名称必填
资源、凭据摘要和初始化审计在一个事务中提交。交付文件先写入并同步到磁盘,成功后再提交事务。如果文件写入失败则回滚并移除文件;如果已经交付后提交无法确认,保留文件并报错,应使用其中凭据核对结果,不能直接覆盖或重复创建。 资源、凭据摘要和初始化审计在一个事务中提交。交付文件先写入并同步到磁盘,成功后再提交事务。如果文件写入失败则回滚并移除文件;如果已经交付后提交无法确认,保留文件并报错,应使用其中凭据核对结果,不能直接覆盖或重复创建。
初始文件包含真实秘密,应保管在受限目录或导入秘密管理系统。运行账户与迁移/初始化账户分开;生产只通过 HTTPS 使用凭据。凭据管理 HTTP API、轮换与细粒度运维命令在后续阶段实现,当前 bootstrap 主要用于完成独立接入与开发验证。 初始文件包含真实秘密,应保管在受限目录或导入秘密管理系统。运行账户与迁移/初始化账户分开;生产只通过 HTTPS 使用凭据。
## 轮换与撤销
凭据轮换和撤销只允许在受保护的数据库管理环境执行,不提供公网管理接口。轮换保留 `credential_id`,因此订阅和 Worker 实例的授权绑定仍指向同一记录;新的 Worker 进程必须按现有进程身份规则重新连接,旧令牌不能再通过任何 API 或 WSS 认证。平台不会批量改写在途任务、交付记录或历史会话证据。
先读取脱敏状态和当前版本:
```sh
go run ./cmd/orbit-admin credential status \
--namespace-id <namespace_id> \
--credential-id <credential_id>
```
状态输出不包含令牌、秘密摘要或权限正文;`revision` 是后续变更的并发条件。
```sh
go run ./cmd/orbit-admin credential rotate \
--namespace-id <namespace_id> \
--credential-id <credential_id> \
--expected-revision 1 \
--valid-for 720h \
--reason "scheduled credential rotation" \
--output .local/rotated.credentials.json
```
命令先读取并核对版本,生成一个固定 `operation_id`、令牌和绝对到期时间,写入新的 `0600` 私有操作文件,然后提交数据库事务。文件不得已存在;提交结果不明、网络断开或数据库返回未知错误时必须保留文件,并使用同一个文件执行 `credential apply --file`,不能重新执行 `rotate` 生成第二个秘密。`--valid-for` 限定在 1 小时到 1 年。数据库只保存秘密摘要、请求摘要和脱敏回执。
```sh
go run ./cmd/orbit-admin credential apply \
--file .local/rotated.credentials.json
```
`apply` 会严格拒绝软链接、非 0600 文件、未知 JSON 字段、重复 JSON 对象和修改过的操作内容。重复应用已提交的文件只返回原回执,回执中的 `deduplicated` 为 `true`,不会重新轮换或新增审计。
发生泄露或 Worker 必须立即停用时执行撤销:
```sh
go run ./cmd/orbit-admin credential revoke \
--namespace-id <namespace_id> \
--credential-id <credential_id> \
--expected-revision 2 \
--output .local/revoke.operation.json \
--reason "incident response"
```
撤销同样先写入固定操作文件,再提交事务;提交不明时用 `credential apply --file .local/revoke.operation.json` 重试。撤销只标记凭据失效,不删除订阅、任务、交付或历史记录。即使应用或命名空间已停用,管理撤销仍可执行。重复应用同一撤销文件只返回原回执,不追加重复审计。审计主体取自 MySQL 管理连接的 `CURRENT_USER()`,审计摘要不包含秘密原文或秘密摘要。
## 凭据格式与作用域 ## 凭据格式与作用域

View File

@@ -0,0 +1,30 @@
# P6:凭据操作与安全轮换
日期:2026-09-18。本文只记录管理凭据操作的已实现边界,不把凭据功能等同于 P6 全部生产验收。
## 已实现行为
- `credential status` 只返回名称、版本、到期/撤销时间和应用/命名空间启用状态,不返回秘密或 `secret_digest`。
- `credential rotate` 先读取并核对 `expected_revision`,生成一次新令牌和固定绝对到期时间,写入新的 `0600` 私有操作文件,再应用事务。
- `credential apply --file` 严格读取同一个私有操作文件。操作身份、请求摘要、凭据摘要更新、脱敏回执和管理审计在同一事务中提交;提交结果不明时重复应用同一文件只返回原回执,不重新生成秘密、不重置到期时间、不新增审计。
- 操作文件被修改、替换为不同令牌或不同原因时返回 `IDEMPOTENCY_CONFLICT`;已有成功回执不受后续凭据轮换或撤销影响。
- `credential revoke` 也使用固定操作文件,允许在应用或命名空间已停用时关闭凭据。撤销只改变凭据授权状态,不批量改写 Worker、订阅、任务、交付或历史会话记录;后续认证和控制操作会重新核对凭据状态。
- 审计主体来自提交事务所在 MySQL 连接的 `CURRENT_USER()`,不是凭据 ID 或请求自报字段。审计前后摘要不包含令牌或秘密摘要。
- 没有公网管理路由,也没有新增运行时依赖;管理 CLI 使用现有 MySQL 连接和迁移账本。
## 真实验证
以下验证使用 Go 1.27.1、darwin/arm64、Docker MySQL 8.4.10 的一次性数据库:
```sh
GO="$(cat /tmp/orbit-toolchain-path)/go" DOCKER=/usr/local/bin/docker \
sh /tmp/orbit-credential-test.sh
```
`TestIntegrationCredentialOperations` 退出 0,覆盖:初始状态、轮换后旧令牌拒绝/新令牌接受、固定操作重放、修改操作文件冲突、停用应用后撤销、重复撤销回执、审计主体和敏感字段不泄露。数据库回读确认轮换审计和撤销审计各一条,当前迁移保持零外键。
`TestIntegrationCredentialCommitFaults` 同样退出 0:使用测试专用 MySQL 包代理分别在 COMMIT 发出前断线和收到成功包后丢确认,独立连接回读版本,并用同一操作文件完成核对。BEFORE 情况回滚后重试首次提交,AFTER 情况直接返回原回执;两种情况都没有生成第二个令牌或第二条审计。该故障代理只存在于测试二进制,不进入生产进程。
随后执行标准隔离回归 `GO="$(cat /tmp/orbit-toolchain-path)/go" DOCKER=/usr/local/bin/docker sh scripts/test-mysql.sh`,退出 0。该次 root MySQL 集成组耗时 264.138 秒,包含上述两个凭据场景;流程运行器、SDK、Worker/事件进程、业务 Inbox/Outbox 和 23 个 SDK 示例均通过。另一次 CLI 专项实际构建 `orbit-admin`,在一次性 MySQL 上跑通 status、rotate、apply 首次提交、apply 重放和 revoke,且新操作文件权限为 0600。
以上证据仍不替代密钥托管演练、多节点重连演练、保留/归档、备份恢复和容量验收。

View File

@@ -154,7 +154,11 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
if err != nil { if err != nil {
return err return err
} }
if err := appendAudit(ctx, tx, auth.Token{NamespaceID: out.NamespaceID, CredentialID: out.CredentialID}, op, out.CredentialID, "credential", "bootstrap.create", "database administrator bootstrap", nil, map[string]any{"permissions": permissions, "expires_at": out.ExpiresAt}); err != nil { actor, err := databaseActor(ctx, tx)
if err != nil {
return err
}
if err := appendAuditActor(ctx, tx, out.NamespaceID, actor, op, "bootstrap.create", out.CredentialID, "database administrator bootstrap", nil, map[string]any{"permissions": permissions, "expires_at": out.ExpiresAt}); err != nil {
return err return err
} }
// 交付文件先持久化再提交;提交失败时保留文件以便核对不确定结果。 // 交付文件先持久化再提交;提交失败时保留文件以便核对不确定结果。

View File

@@ -0,0 +1,195 @@
package mysql
import (
"context"
"encoding/json"
"os"
"strings"
"testing"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
// TestIntegrationCredentialOperations verifies the private operation protocol:
// one frozen token, stable idempotency and audit identity from MySQL itself.
func TestIntegrationCredentialOperations(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("set ORBIT_TEST_MYSQL_DSN to a disposable database, or run make integration")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
db, err := Open(ctx, dsn, 8, 2)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)
}
var initial BootstrapResult
name := "credential-" + string(testID(t))
err = Bootstrap(ctx, db, BootstrapInput{Application: name, Namespace: "default", Environment: "test", Queue: "credential"}, func(v BootstrapResult) error {
initial = v
return nil
})
if err != nil {
t.Fatal(err)
}
status, err := GetCredentialStatus(ctx, db, initial.NamespaceID, initial.CredentialID)
if err != nil || status.Revision != 1 || status.RevokedAt != nil {
t.Fatalf("initial credential status: %+v %v", status, err)
}
old, err := auth.Parse(initial.Token)
if err != nil {
t.Fatal(err)
}
if err := NewTaskStore(db).AuthorizeSubmit(ctx, old); err != nil {
t.Fatalf("initial credential rejected: %v", err)
}
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{
NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 1,
Action: credentialRotateAction, ValidFor: 2 * time.Hour, Reason: "scheduled rotation",
})
if err != nil {
t.Fatal(err)
}
if op.Token == initial.Token || op.OperationID == "" || op.ExpiresAt == nil {
t.Fatal("prepare did not freeze a new token and expiry")
}
receipt, err := ApplyCredentialOperation(ctx, db, op)
if err != nil || receipt.Revision != 2 || receipt.Deduplicated {
t.Fatalf("rotation receipt: %+v %v", receipt, err)
}
newToken, err := auth.Parse(op.Token)
if err != nil || NewTaskStore(db).AuthorizeSubmit(ctx, old) == nil || NewTaskStore(db).AuthorizeSubmit(ctx, newToken) != nil {
t.Fatalf("credential authentication boundary failed: %v", err)
}
replayed, err := ApplyCredentialOperation(ctx, db, op)
if err != nil || !replayed.Deduplicated || replayed.OperationID != receipt.OperationID || replayed.Revision != receipt.Revision {
t.Fatalf("stable operation replay: %+v %v", replayed, err)
}
changed := op
changed.Token, _, err = auth.Issue(initial.NamespaceID, initial.CredentialID)
if err != nil {
t.Fatal(err)
}
if _, err := ApplyCredentialOperation(ctx, db, changed); fault.Code(err) != "IDEMPOTENCY_CONFLICT" {
t.Fatalf("changed operation file was accepted: %v", err)
}
// Revocation remains available for a disabled application so an incident can
// close credentials even when ordinary authentication is already unavailable.
if _, err := db.ExecContext(ctx, "UPDATE orbit_applications SET enabled=FALSE WHERE id=?", initial.ApplicationID); err != nil {
t.Fatal(err)
}
revoke, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{
NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 2,
Action: credentialRevokeAction, Reason: "incident response",
})
if err != nil {
t.Fatal(err)
}
revoked, err := ApplyCredentialOperation(ctx, db, revoke)
if err != nil || revoked.Revision != 3 || revoked.RevokedAt == nil {
t.Fatalf("revoke receipt: %+v %v", revoked, err)
}
if _, err := ApplyCredentialOperation(ctx, db, revoke); err != nil {
t.Fatal(err)
}
if err := NewTaskStore(db).AuthorizeSubmit(ctx, newToken); fault.Code(err) != "UNAUTHENTICATED" {
t.Fatalf("revoked token remained usable: %v", err)
}
var audits int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_audit_events WHERE namespace_id=? AND resource_id=? AND action IN ('credential.rotate','credential.revoke')`, initial.NamespaceID, initial.CredentialID).Scan(&audits); err != nil || audits != 2 {
t.Fatalf("credential audit count=%d err=%v", audits, err)
}
var actor, before, after string
if err := db.QueryRowContext(ctx, `SELECT actor_id,`+"`before`"+`,`+"`after`"+` FROM orbit_audit_events WHERE namespace_id=? AND operation_id=?`, initial.NamespaceID, op.OperationID).Scan(&actor, &before, &after); err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(actor, "mysql:") || actor == string(initial.CredentialID) || strings.Contains(before+after, op.Token) || strings.Contains(before+after, "secret_digest") {
t.Fatalf("audit leaked or misidentified operator: actor=%q before=%s after=%s", actor, before, after)
}
var stored []byte
if err := db.QueryRowContext(ctx, `SELECT response FROM orbit_idempotency_keys WHERE namespace_id=? AND id=?`, initial.NamespaceID, op.OperationID).Scan(&stored); err != nil {
t.Fatal(err)
}
var saved CredentialChangeReceipt
if err := json.Unmarshal(stored, &saved); err != nil || saved.OperationID != op.OperationID {
t.Fatalf("stored receipt: %s %v", stored, err)
}
t.Log("credential operation file froze one token, replay returned the original receipt, tampering conflicted, and disabled-scope revocation preserved audit identity")
}
func TestIntegrationCredentialCommitFaults(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("set ORBIT_TEST_MYSQL_DSN to a disposable database, or run make integration")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
db, err := Open(ctx, dsn, 8, 2)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)
}
for _, after := range []bool{false, true} {
name := "credential-commit-" + string(testID(t))
var initial BootstrapResult
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"})
if err != nil {
t.Fatal(err)
}
proxy, proxiedDSN := newCommitFaultProxy(t, dsn)
faultDB, err := Open(ctx, proxiedDSN, 2, 1)
if err != nil {
t.Fatal(err)
}
if after {
proxy.mode.Store(2)
} else {
proxy.mode.Store(1)
}
_, applyErr := ApplyCredentialOperation(ctx, faultDB, op)
proxy.expect(t, after, applyErr)
faultDB.Close()
status, err := GetCredentialStatus(ctx, db, initial.NamespaceID, initial.CredentialID)
if err != nil {
t.Fatal(err)
}
wantRevision := uint64(1)
if after {
wantRevision = 2
}
if status.Revision != wantRevision {
t.Fatalf("commit %v status revision=%d want=%d", after, status.Revision, wantRevision)
}
receipt, err := ApplyCredentialOperation(ctx, db, op)
if err != nil || receipt.Revision != 2 || receipt.Deduplicated != after {
t.Fatalf("commit %v reconciliation receipt=%+v err=%v", after, receipt, err)
}
}
}
func testID(t *testing.T) core.ID {
t.Helper()
id, err := core.NewID()
if err != nil {
t.Fatal(err)
}
return id
}

View File

@@ -0,0 +1,431 @@
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
}