feat: manage existing credential actions with audited replay

This commit is contained in:
2026-11-01 19:10:37 +08:00
parent baa81c4923
commit 98ad60c0b8
18 changed files with 1350 additions and 53 deletions

View File

@@ -9,6 +9,7 @@ import (
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
@@ -16,17 +17,20 @@ import (
"git.yuntuo.hk/k2safe/orbit/internal/credentialvault"
"git.yuntuo.hk/k2safe/orbit/internal/storage/mysql"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
func runCredential(args []string) error {
if len(args) == 0 {
return errors.New("usage: orbit-admin credential status|rotate|expiry|finalize|revoke|apply")
return errors.New("usage: orbit-admin credential status|permissions|rotate|expiry|finalize|revoke|apply")
}
switch args[0] {
case "status":
return runCredentialStatus(args[1:])
case "rotate":
return runCredentialChange(args[1:], "ROTATE")
case "permissions":
return runCredentialChange(args[1:], "SET_PERMISSIONS")
case "expiry":
return runCredentialChange(args[1:], "SET_EXPIRY")
case "finalize":
@@ -36,7 +40,7 @@ func runCredential(args []string) error {
case "apply":
return runCredentialApply(args[1:])
default:
return errors.New("usage: orbit-admin credential status|rotate|expiry|finalize|revoke|apply")
return errors.New("usage: orbit-admin credential status|permissions|rotate|expiry|finalize|revoke|apply")
}
}
@@ -75,6 +79,11 @@ func runCredentialChange(args []string, action string) error {
grace = 7 * 24 * time.Hour
}
var expected uint64
var actionList string
prepareOnly := flags.Bool("prepare-only", false, "save the fixed private operation without applying it; later use credential apply")
if action == "SET_PERMISSIONS" {
flags.StringVar(&actionList, "actions", "", "complete comma-separated permission actions; existing queue scope is preserved")
}
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")
@@ -92,6 +101,22 @@ func runCredentialChange(args []string, action string) error {
if err != nil || expected == 0 {
return errors.New("credential change requires valid ids and a nonzero --expected-revision")
}
var actions []string
if action == "SET_PERMISSIONS" {
invalidLifetime := false
flags.Visit(func(f *flag.Flag) {
if f.Name == "valid-for" || f.Name == "grace" {
invalidLifetime = true
}
})
if invalidLifetime {
return errors.New("permission changes preserve validity and overlap; omit --valid-for and --grace")
}
actions, err = parseCredentialActions(actionList)
if err != nil {
return err
}
}
duration := time.Duration(0)
if action == "ROTATE" || action == "SET_EXPIRY" {
duration, err = time.ParseDuration(validFor)
@@ -105,7 +130,7 @@ func runCredentialChange(args []string, action string) error {
}
defer closeDB()
op, err := mysql.PrepareCredentialOperation(ctx, db, mysql.CredentialChangeInput{
NamespaceID: ns, CredentialID: id, ExpectedRevision: expected, Action: action, ValidFor: duration, GraceFor: grace, Reason: reason,
NamespaceID: ns, CredentialID: id, ExpectedRevision: expected, Action: action, ValidFor: duration, GraceFor: grace, Reason: reason, Actions: actions,
})
if err != nil {
return errors.New("credential operation could not be prepared")
@@ -113,6 +138,12 @@ func runCredentialChange(args []string, action string) error {
if err := writePrivateOperation(output, op); err != nil {
return err
}
if *prepareOnly {
return json.NewEncoder(os.Stdout).Encode(struct {
OperationID core.ID `json:"operation_id"`
Prepared bool `json:"prepared"`
}{op.OperationID, true})
}
vault, err := credentialvault.LoadFile(os.Getenv("ORBIT_CREDENTIAL_KEY_FILE"))
if err != nil {
return err
@@ -124,6 +155,24 @@ func runCredentialChange(args []string, action string) error {
return json.NewEncoder(os.Stdout).Encode(receipt)
}
func parseCredentialActions(value string) ([]string, error) {
actions := strings.Split(value, ",")
seen := make(map[string]bool, len(actions))
for i, action := range actions {
action = strings.TrimSpace(action)
if action == "" || seen[action] {
return nil, errors.New("--actions requires a nonempty, unique, complete action list")
}
seen[action] = true
actions[i] = action
}
if err := (auth.Permissions{Actions: actions, Queues: []string{"*"}}).Validate(); err != nil {
return nil, errors.New("--actions contains an unsupported action or exceeds the action limit")
}
sort.Strings(actions)
return actions, nil
}
func runCredentialApply(args []string) error {
flags := flag.NewFlagSet("credential apply", flag.ContinueOnError)
file := ""