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 := ""

View File

@@ -3,6 +3,7 @@ package main
import (
"os"
"path/filepath"
"reflect"
"testing"
"time"
@@ -84,3 +85,42 @@ func TestPrivateOperationFileRejectsUnknownFieldsAndSymlinks(t *testing.T) {
t.Fatal("symlink operation file was accepted")
}
}
func TestPermissionActionList(t *testing.T) {
actions, err := parseCredentialActions("tasks:read, schedules:manage,schedules:read")
if err != nil || !reflect.DeepEqual(actions, []string{"schedules:manage", "schedules:read", "tasks:read"}) {
t.Fatalf("invalid canonical action list: %v %v", actions, err)
}
for _, value := range []string{"", " ", "*", "tasks:*", "tasks:read,tasks:read", "tasks:read, tasks:read", "tasks:read,", "schedules:admin"} {
if _, err := parseCredentialActions(value); err == nil {
t.Fatalf("accepted invalid action list %q", value)
}
}
}
func TestPermissionCommandRejectsLifetimeBeforeDatabaseAccess(t *testing.T) {
op := testOperation(t)
for _, flag := range []string{"--valid-for=0", "--grace=0"} {
err := runCredential([]string{"permissions", "--namespace-id=" + string(op.NamespaceID), "--credential-id=" + string(op.CredentialID), "--expected-revision=1", "--reason=test", "--output=" + filepath.Join(t.TempDir(), "op.json"), "--actions=tasks:read", flag})
if err == nil || err.Error() != "permission changes preserve validity and overlap; omit --valid-for and --grace" {
t.Fatalf("lifetime field not rejected: %v", err)
}
}
}
func TestPrivatePermissionOperationReplaysWithoutToken(t *testing.T) {
op := testOperation(t)
op.Version = 3
op.Action = "SET_PERMISSIONS"
op.Token = ""
op.ExpiresAt = nil
op.Actions = []string{"schedules:manage", "schedules:read", "tasks:read"}
path := filepath.Join(t.TempDir(), "permissions.json")
if err := writePrivateOperation(path, op); err != nil {
t.Fatal(err)
}
read, err := readPrivateOperation(path)
if err != nil || !reflect.DeepEqual(read, op) {
t.Fatalf("permission operation identity changed: %v", err)
}
}

View File

@@ -28,7 +28,7 @@ func main() {
}
func run() error {
if len(os.Args) < 2 {
return errors.New("usage: orbit-admin release manifest|bootstrap|console-user create|verify|observe snapshot|retention inventory|archive|verify-archive|credential status|rotate|revoke|apply")
return errors.New("usage: orbit-admin release manifest|bootstrap|console-user create|verify|observe snapshot|retention inventory|archive|verify-archive|credential status|permissions|rotate|expiry|finalize|revoke|apply")
}
switch os.Args[1] {
case "release":
@@ -46,7 +46,7 @@ func run() error {
case "credential":
return runCredential(os.Args[2:])
}
return errors.New("usage: orbit-admin release manifest|bootstrap|console-user create|verify|observe snapshot|retention inventory|archive|verify-archive|credential status|rotate|revoke|apply")
return errors.New("usage: orbit-admin release manifest|bootstrap|console-user create|verify|observe snapshot|retention inventory|archive|verify-archive|credential status|permissions|rotate|expiry|finalize|revoke|apply")
}
// releaseManifest 是发布物自身的静态身份清单,不读取数据库、不包含 DSN、凭据或业务数据。