feat: make credential changes replayable
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user