package main import ( "context" "database/sql" "encoding/json" "errors" "flag" "io" "os" "path/filepath" "sort" "strings" "time" "git.yuntuo.hk/k2safe/orbit/internal/config" "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|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": return runCredentialChange(args[1:], "FINALIZE") case "revoke": return runCredentialChange(args[1:], "REVOKE") case "apply": return runCredentialApply(args[1:]) default: return errors.New("usage: orbit-admin credential status|permissions|rotate|expiry|finalize|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 := "", "", "", "", "0" grace := time.Duration(0) if action == "ROTATE" { 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") 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", "0", "credential validity: 0 permanent, or 1h to 8760h") flags.DurationVar(&grace, "grace", grace, "rotation overlap: 0 immediate, up to 720h; default 168h") 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") } 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) 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, GraceFor: grace, Reason: reason, Actions: actions, }) if err != nil { return errors.New("credential operation could not be prepared") } 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 } receipt, err := mysql.ApplyCredentialOperation(ctx, db, op, vault) 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 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 := "" 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() vault, err := credentialvault.LoadFile(os.Getenv("ORBIT_CREDENTIAL_KEY_FILE")) if err != nil { return err } receipt, err := mysql.ApplyCredentialOperation(ctx, db, op, vault) 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 }