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

@@ -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 {
if len(os.Args) < 2 || os.Args[1] != "bootstrap" {
return errors.New("usage: orbit-admin bootstrap (--application NAME | --application-id ID) --namespace NAME --environment NAME --queue NAME --output NEW_FILE")
if len(os.Args) < 2 {
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)
var in mysql.BootstrapInput
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.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")
if err := flags.Parse(os.Args[2:]); err != nil {
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 || output == "" {