API keys were a pure in-memory map (store/apikey.go) — every apiserver restart silently revoked every service-account key (advisor, MCP server) until re-issued, with only an inexplicable 401 to show for it. Keys now persist to <top>/state/apikeys.json: hash → metadata only (never the raw key, preserving the existing "a dump cannot leak usable credentials" property), written atomically (temp file + fsync + rename, 0600) on each issuance, loaded at InitStores. The load also restores the user→role mapping, without which a reloaded key would authenticate but be denied every permission check. A missing file is a normal fresh install; a corrupt one fails fast at startup instead of quietly starting with zero keys. A failed save fails the issuance request — the raw key is shown exactly once, so we must not hand out a key we could not persist. The memory-only path (no SetAPIKeyStatePath) is kept for unit tests. Cluster-verified on t1-t3: issue key → restart apiserver → key still authenticates (hosts OK, advisor:report POST published 1) — previously impossible. Verify job submitted, ran, exited; all daemons healthy. Co-Authored-By: Claude Code <noreply@anthropic.com>
235 lines
6.7 KiB
Go
235 lines
6.7 KiB
Go
package store
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func resetAPIKeyState() {
|
|
SetAPIKeyStatePath("")
|
|
InitStores()
|
|
}
|
|
|
|
func TestHashAPIKey_Deterministic(t *testing.T) {
|
|
h1 := HashAPIKey("xce_ak_abc123")
|
|
h2 := HashAPIKey("xce_ak_abc123")
|
|
if h1 != h2 {
|
|
t.Errorf("HashAPIKey should be deterministic: %q != %q", h1, h2)
|
|
}
|
|
h3 := HashAPIKey("xce_ak_different")
|
|
if h1 == h3 {
|
|
t.Error("HashAPIKey should differ for different inputs")
|
|
}
|
|
}
|
|
|
|
func TestHashAPIKey_NeverPlaintext(t *testing.T) {
|
|
raw := "xce_ak_secret-value"
|
|
hashed := HashAPIKey(raw)
|
|
if hashed == raw {
|
|
t.Error("HashAPIKey must not return the raw key unchanged")
|
|
}
|
|
}
|
|
|
|
func TestAddAPIKey_ValidateAPIKey_RoundTrip(t *testing.T) {
|
|
resetAPIKeyState()
|
|
|
|
raw := "xce_ak_roundtrip"
|
|
entry := APIKeyEntry{
|
|
Username: "mcp-bot",
|
|
RoleName: "mcp_readonly",
|
|
CreateTime: time.Now(),
|
|
ExpiryTime: time.Now().Add(24 * time.Hour),
|
|
}
|
|
AddAPIKey(raw, entry)
|
|
|
|
got, ok := ValidateAPIKey(raw)
|
|
if !ok {
|
|
t.Fatal("expected ValidateAPIKey to succeed for a freshly-added key")
|
|
}
|
|
if got.Username != "mcp-bot" {
|
|
t.Errorf("Username = %q, want mcp-bot", got.Username)
|
|
}
|
|
if got.RoleName != "mcp_readonly" {
|
|
t.Errorf("RoleName = %q, want mcp_readonly", got.RoleName)
|
|
}
|
|
}
|
|
|
|
func TestValidateAPIKey_Unknown(t *testing.T) {
|
|
resetAPIKeyState()
|
|
|
|
_, ok := ValidateAPIKey("xce_ak_never-issued")
|
|
if ok {
|
|
t.Error("expected ValidateAPIKey to fail for a key that was never added")
|
|
}
|
|
}
|
|
|
|
func TestValidateAPIKey_Expired(t *testing.T) {
|
|
resetAPIKeyState()
|
|
|
|
raw := "xce_ak_expired"
|
|
AddAPIKey(raw, APIKeyEntry{
|
|
Username: "mcp-bot",
|
|
RoleName: "mcp_readonly",
|
|
CreateTime: time.Now().Add(-48 * time.Hour),
|
|
ExpiryTime: time.Now().Add(-1 * time.Hour), // already expired
|
|
})
|
|
|
|
_, ok := ValidateAPIKey(raw)
|
|
if ok {
|
|
t.Error("expected ValidateAPIKey to fail for an expired key")
|
|
}
|
|
}
|
|
|
|
func TestValidateAPIKey_StoredByHashNotPlaintext(t *testing.T) {
|
|
resetAPIKeyState()
|
|
|
|
raw := "xce_ak_hashed-storage"
|
|
AddAPIKey(raw, APIKeyEntry{Username: "mcp-bot", ExpiryTime: time.Now().Add(time.Hour)})
|
|
|
|
if APIKeys.Has(raw) {
|
|
t.Error("store must not contain the raw key as a map key")
|
|
}
|
|
if !APIKeys.Has(HashAPIKey(raw)) {
|
|
t.Error("store should contain the hashed key")
|
|
}
|
|
}
|
|
|
|
// ========== Persistence across restarts ==========
|
|
|
|
// TestAPIKeyPersistence_ReloadSurvivesRestart simulates an apiserver restart:
|
|
// issue keys against one store instance with a state file, then re-run
|
|
// InitStores (fresh memory) and verify every key still validates and its
|
|
// user→role mapping was restored — the exact failure mode that made every
|
|
// apiserver restart silently revoke all service-account keys before.
|
|
func TestAPIKeyPersistence_ReloadSurvivesRestart(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "apikeys.json")
|
|
SetAPIKeyStatePath(path)
|
|
defer resetAPIKeyState()
|
|
|
|
InitStores()
|
|
raw := "xce_ak_survives-restart"
|
|
AddAPIKey(raw, APIKeyEntry{
|
|
Username: "advisor-svc",
|
|
RoleName: "advisor_agent",
|
|
CreateTime: time.Now(),
|
|
ExpiryTime: time.Now().Add(90 * 24 * time.Hour),
|
|
})
|
|
|
|
// Restart: brand-new store over the same state path.
|
|
InitStores()
|
|
|
|
entry, ok := ValidateAPIKey(raw)
|
|
if !ok {
|
|
t.Fatal("key lost across restart — state file was not reloaded")
|
|
}
|
|
if entry.Username != "advisor-svc" || entry.RoleName != "advisor_agent" {
|
|
t.Errorf("entry = %+v, want advisor-svc/advisor_agent", entry)
|
|
}
|
|
// Role mapping must be restored too, or the key authenticates but is
|
|
// denied every permission check after restart.
|
|
roles := GetUserRoleNames("advisor-svc")
|
|
if len(roles) != 1 || roles[0] != "advisor_agent" {
|
|
t.Errorf("GetUserRoleNames(advisor-svc) = %v, want [advisor_agent]", roles)
|
|
}
|
|
}
|
|
|
|
// TestAPIKeyPersistence_FileNeverContainsRawKey verifies the state file holds
|
|
// only hashes — reading it must not leak usable credentials.
|
|
func TestAPIKeyPersistence_FileNeverContainsRawKey(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "apikeys.json")
|
|
SetAPIKeyStatePath(path)
|
|
defer resetAPIKeyState()
|
|
|
|
InitStores()
|
|
raw := "xce_ak_secret-never-on-disk"
|
|
AddAPIKey(raw, APIKeyEntry{Username: "mcp-bot", ExpiryTime: time.Now().Add(time.Hour)})
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("state file missing: %v", err)
|
|
}
|
|
if string(data) != "" && containsPlain(data, raw) {
|
|
t.Error("state file contains the raw key — must store hashes only")
|
|
}
|
|
var state persistedAPIKeyState
|
|
if err := json.Unmarshal(data, &state); err != nil {
|
|
t.Fatalf("state file is not valid JSON: %v", err)
|
|
}
|
|
if len(state.Keys) != 1 {
|
|
t.Fatalf("state.Keys = %d entries, want 1", len(state.Keys))
|
|
}
|
|
for hash := range state.Keys {
|
|
if hash != HashAPIKey(raw) {
|
|
t.Errorf("stored key %q is not the expected SHA-256 hash", hash)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAPIKeyPersistence_CorruptFileFailsFast verifies a corrupt state file
|
|
// makes InitStores panic instead of silently starting with zero keys (which
|
|
// would turn every service account into a mystery 401).
|
|
func TestAPIKeyPersistence_CorruptFileFailsFast(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "apikeys.json")
|
|
SetAPIKeyStatePath(path)
|
|
|
|
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resetAPIKeyState()
|
|
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Error("InitStores did not fail on a corrupt state file")
|
|
}
|
|
}()
|
|
InitStores()
|
|
}
|
|
|
|
// TestAPIKeyPersistence_MissingFileIsFreshInstall verifies a missing state
|
|
// file is the normal fresh-install case, not an error.
|
|
func TestAPIKeyPersistence_MissingFileIsFreshInstall(t *testing.T) {
|
|
SetAPIKeyStatePath(filepath.Join(t.TempDir(), "does-not-exist.json"))
|
|
defer resetAPIKeyState()
|
|
|
|
InitStores() // must not panic
|
|
}
|
|
|
|
// TestAPIKeyPersistence_SaveFailurePropagates verifies a state file that
|
|
// cannot be written makes AddAPIKey return an error (the controller turns
|
|
// this into a failed request rather than issuing a key that dies on restart).
|
|
func TestAPIKeyPersistence_SaveFailurePropagates(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "apikeys.json")
|
|
SetAPIKeyStatePath(path)
|
|
defer resetAPIKeyState()
|
|
|
|
InitStores()
|
|
// A directory where the state file lives makes saveAPIKeyState's rename
|
|
// fail — os.Rename(file, dir) fails with EISDIR/ENOTDIR on Linux.
|
|
if err := os.Mkdir(path, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err := AddAPIKey("xce_ak_unwritable", APIKeyEntry{Username: "x"})
|
|
if err == nil {
|
|
t.Error("AddAPIKey did not report the failed state save")
|
|
}
|
|
}
|
|
|
|
func containsPlain(data []byte, needle string) bool {
|
|
return len(needle) > 0 && string(data) != "" && jsonContains(data, []byte(needle))
|
|
}
|
|
|
|
func jsonContains(haystack, needle []byte) bool {
|
|
for i := 0; i+len(needle) <= len(haystack); i++ {
|
|
if string(haystack[i:i+len(needle)]) == string(needle) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|