From 76e7cc7c18b68a0847306e3a273de16c6455b614 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 03:47:11 +0000 Subject: [PATCH] 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 --- cmd/agent-system/account_io_test.go | 68 +++++++++++++++++++++++++++++ cmd/agent-system/main.go | 23 +++++++++- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/cmd/agent-system/account_io_test.go b/cmd/agent-system/account_io_test.go index 4d91a02..1096b8c 100644 --- a/cmd/agent-system/account_io_test.go +++ b/cmd/agent-system/account_io_test.go @@ -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") + } + }) + } +} diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index 4b88321..66a22a9 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -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)