Deliver API keys through exclusive private files
Why: API-key issuance shares the account maintenance no-create and credential-output contract; the old CLI printed its key and lacked --out-file. Evidence: The new subprocess test failed before the flag existed, then passed for private output and pre-mutation collision refusal; current go vet and all Go tests pass. Impact: apikey create accepts protected --dsn-file and --out-file, preserves stdout default, and never overwrites an existing destination. Refs: Backend PR #185; Dev PR #486; U7 KTD14
This commit is contained in:
@@ -2,8 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/store"
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
@@ -88,3 +90,69 @@ func TestExistingOutputAndConflictingFormatsRefuseBeforeMutation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKeyCreateUsesExclusivePrivateOutput(t *testing.T) {
|
||||
if dir := os.Getenv("U7_APIKEY_CHILD_DIR"); dir != "" {
|
||||
rc := runAPIKey([]string{"create", "--data", dir, "--owner", "ada", "--name", "fixture", "--out-file", os.Getenv("U7_APIKEY_CHILD_OUT")})
|
||||
if rc != 0 {
|
||||
t.Fatal("apikey create rejected its private output destination")
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, existing := range []bool{false, true} {
|
||||
name := "new"
|
||||
if existing {
|
||||
name = "existing"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
dir, st := cliStore(t)
|
||||
if err := st.CreateAccount("ada", "Ada", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := filepath.Join(t.TempDir(), "credential")
|
||||
if existing {
|
||||
if err := os.WriteFile(out, []byte("preserve"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestAPIKeyCreateUsesExclusivePrivateOutput$")
|
||||
cmd.Env = append(os.Environ(), "U7_APIKEY_CHILD_DIR="+dir, "U7_APIKEY_CHILD_OUT="+out)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
if existing {
|
||||
if err == nil {
|
||||
t.Fatal("existing output was overwritten or mutation succeeded")
|
||||
}
|
||||
raw, readErr := os.ReadFile(out)
|
||||
if readErr != nil || string(raw) != "preserve" {
|
||||
t.Fatal("existing output was modified")
|
||||
}
|
||||
keys, listErr := st.ListAPIKeys("ada")
|
||||
if listErr != nil || len(keys) != 0 {
|
||||
t.Fatal("mutation happened before output preflight")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal("new private output was rejected")
|
||||
}
|
||||
info, statErr := os.Stat(out)
|
||||
if statErr != nil || info.Mode().Perm() != 0600 {
|
||||
t.Fatal("credential output missing or not private")
|
||||
}
|
||||
raw, readErr := os.ReadFile(out)
|
||||
if readErr != nil || len(raw) == 0 {
|
||||
t.Fatal("credential output empty")
|
||||
}
|
||||
if bytes.Contains(stdout.Bytes(), bytes.TrimSpace(raw)) {
|
||||
t.Fatal("credential was written to stdout")
|
||||
}
|
||||
keys, listErr := st.ListAPIKeys("ada")
|
||||
if listErr != nil || len(keys) != 1 {
|
||||
t.Fatal("successful issue was not committed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,22 +196,43 @@ func runAPIKey(args []string) int {
|
||||
fs := flag.NewFlagSet("apikey "+sub, flag.ExitOnError)
|
||||
data := fs.String("data", "./data", "数据目录")
|
||||
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)")
|
||||
dsnFile := fs.String("dsn-file", "", "0600权限的PostgreSQL DSN文件")
|
||||
owner := fs.String("owner", "", "租户名(owner)")
|
||||
name := fs.String("name", "default", "密钥名称")
|
||||
outFile := fs.String("out-file", "", "把明文密钥独占写入该文件(0600)")
|
||||
fs.Parse(args[1:])
|
||||
if *owner == "" {
|
||||
fmt.Fprintln(os.Stderr, "apikey: --owner 必填")
|
||||
return 2
|
||||
}
|
||||
if sub != "create" && *outFile != "" {
|
||||
fmt.Fprintln(os.Stderr, "apikey: 此子命令不生成密钥")
|
||||
return 2
|
||||
}
|
||||
resolvedDSN, err := resolveAccountDSN(*dsn, *dsnFile)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "apikey:", err)
|
||||
return 1
|
||||
}
|
||||
*dsn = resolvedDSN
|
||||
switch sub {
|
||||
case "create":
|
||||
delivery, err := reserveCredentialOutput(*outFile, "")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "apikey create:", err)
|
||||
return 1
|
||||
}
|
||||
defer delivery.abort()
|
||||
key, err := app.CreateAPIKey(*dsn, *data, *owner, *name)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "apikey create:", err)
|
||||
return 1
|
||||
}
|
||||
// 明文仅此一次输出;库中只存 SHA-256 摘要。
|
||||
fmt.Println(key)
|
||||
if err := delivery.deliver("api-key", *owner, key, nil); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "apikey create:", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case "list":
|
||||
keys, err := app.ListAPIKeys(*dsn, *data, *owner)
|
||||
|
||||
Reference in New Issue
Block a user