feat: make credential changes replayable
This commit is contained in:
@@ -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解释成历史令牌仍有效,也不得级联删除订阅、任务或事件。
|
||||||
|
|||||||
223
cmd/orbit-admin/credentials.go
Normal file
223
cmd/orbit-admin/credentials.go
Normal 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
|
||||||
|
}
|
||||||
86
cmd/orbit-admin/credentials_test.go
Normal file
86
cmd/orbit-admin/credentials_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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 功能范围核对
|
||||||
|
|||||||
@@ -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 流程编排模块与公共能力。平台面向多个业务系统复用。
|
||||||
|
|
||||||
## 阅读顺序
|
## 阅读顺序
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|||||||
@@ -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()`,审计摘要不包含秘密原文或秘密摘要。
|
||||||
|
|
||||||
## 凭据格式与作用域
|
## 凭据格式与作用域
|
||||||
|
|
||||||
|
|||||||
30
docs/reports/phase-6-credentials.md
Normal file
30
docs/reports/phase-6-credentials.md
Normal 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。
|
||||||
|
|
||||||
|
以上证据仍不替代密钥托管演练、多节点重连演练、保留/归档、备份恢复和容量验收。
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
// 交付文件先持久化再提交;提交失败时保留文件以便核对不确定结果。
|
// 交付文件先持久化再提交;提交失败时保留文件以便核对不确定结果。
|
||||||
|
|||||||
195
internal/storage/mysql/credential_integration_test.go
Normal file
195
internal/storage/mysql/credential_integration_test.go
Normal 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
|
||||||
|
}
|
||||||
431
internal/storage/mysql/credentials.go
Normal file
431
internal/storage/mysql/credentials.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user