Files
orbit/cmd/orbit-admin/credentials.go

233 lines
7.5 KiB
Go

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|expiry|finalize|revoke|apply")
}
switch args[0] {
case "status":
return runCredentialStatus(args[1:])
case "rotate":
return runCredentialChange(args[1:], "ROTATE")
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|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
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")
}
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,
})
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
}