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
|
||||
}
|
||||
86
cmd/orbit-admin/credentials_test.go
Normal file
86
cmd/orbit-admin/credentials_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user