security: load Creator secrets from files
CI / test (pull_request) Successful in 10s
CI / test (push) Successful in 10s

This commit was merged in pull request #1.
This commit is contained in:
m
2026-08-16 17:57:32 +03:00
parent e9594306e5
commit 03dde8c196
7 changed files with 190 additions and 7 deletions
+40 -2
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
"fmt"
"git.cin.su/m/Assistant/adapters/postgres"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/identity"
@@ -27,8 +28,20 @@ func main() {
if addr == "" {
addr = ":8080"
}
bootstrapToken, sessionKey := os.Getenv("CREATOR_BOOTSTRAP_TOKEN"), []byte(os.Getenv("CREATOR_SESSION_SIGNING_KEY"))
creatorUsername, creatorPassword := os.Getenv("CREATOR_USERNAME"), os.Getenv("CREATOR_PASSWORD")
bootstrapToken, err := readRequiredSecret("CREATOR_BOOTSTRAP_TOKEN")
if err != nil {
log.Fatal(err)
}
sessionKeyValue, err := readRequiredSecret("CREATOR_SESSION_SIGNING_KEY")
if err != nil {
log.Fatal(err)
}
creatorPassword, err := readRequiredSecret("CREATOR_PASSWORD")
if err != nil {
log.Fatal(err)
}
sessionKey := []byte(sessionKeyValue)
creatorUsername := os.Getenv("CREATOR_USERNAME")
repository, err := postgres.OpenSituation(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("POSTGRES_USER"), Password: os.Getenv("POSTGRES_PASSWORD")})
if err != nil {
log.Fatal(err)
@@ -299,3 +312,28 @@ func bearerToken(r *http.Request) string {
}
return strings.TrimPrefix(value, "Bearer ")
}
func readRequiredSecret(name string) (string, error) {
direct, directSet := os.LookupEnv(name)
fileName, fileSet := os.LookupEnv(name + "_FILE")
if directSet && fileSet {
return "", fmt.Errorf("%s and %s_FILE cannot both be configured", name, name)
}
if fileSet {
if fileName == "" {
return "", fmt.Errorf("%s_FILE is empty", name)
}
contents, err := os.ReadFile(fileName)
if err != nil {
return "", fmt.Errorf("read %s_FILE: %w", name, err)
}
direct = strings.TrimRight(string(contents), "\r\n")
if strings.ContainsAny(direct, "\r\n") {
return "", fmt.Errorf("%s_FILE must contain one line", name)
}
}
if direct == "" {
return "", fmt.Errorf("%s is required", name)
}
return direct, nil
}
+58
View File
@@ -4,6 +4,8 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -67,3 +69,59 @@ func TestCreatorSessionHandlerPreservesBootstrapRecovery(t *testing.T) {
t.Fatalf("bootstrap recovery status = %d", response.Code)
}
}
func TestReadRequiredSecretFromEnvironment(t *testing.T) {
t.Setenv("TEST_CREATOR_SECRET", "direct-value")
value, err := readRequiredSecret("TEST_CREATOR_SECRET")
if err != nil || value != "direct-value" {
t.Fatalf("value=%q err=%v", value, err)
}
}
func TestReadRequiredSecretFromFile(t *testing.T) {
fileName := filepath.Join(t.TempDir(), "secret")
if err := os.WriteFile(fileName, []byte("file-value\r\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("TEST_CREATOR_SECRET_FILE", fileName)
value, err := readRequiredSecret("TEST_CREATOR_SECRET")
if err != nil || value != "file-value" {
t.Fatalf("value=%q err=%v", value, err)
}
}
func TestReadRequiredSecretFailsClosed(t *testing.T) {
t.Run("both sources", func(t *testing.T) {
t.Setenv("TEST_CREATOR_SECRET", "direct")
t.Setenv("TEST_CREATOR_SECRET_FILE", filepath.Join(t.TempDir(), "secret"))
if _, err := readRequiredSecret("TEST_CREATOR_SECRET"); err == nil {
t.Fatal("expected ambiguous configuration to fail")
}
})
t.Run("empty file", func(t *testing.T) {
fileName := filepath.Join(t.TempDir(), "secret")
if err := os.WriteFile(fileName, nil, 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("TEST_CREATOR_SECRET_FILE", fileName)
if _, err := readRequiredSecret("TEST_CREATOR_SECRET"); err == nil {
t.Fatal("expected empty secret to fail")
}
})
t.Run("multiline file", func(t *testing.T) {
fileName := filepath.Join(t.TempDir(), "secret")
if err := os.WriteFile(fileName, []byte("first\nsecond\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("TEST_CREATOR_SECRET_FILE", fileName)
if _, err := readRequiredSecret("TEST_CREATOR_SECRET"); err == nil {
t.Fatal("expected multiline secret to fail")
}
})
t.Run("missing file", func(t *testing.T) {
t.Setenv("TEST_CREATOR_SECRET_FILE", filepath.Join(t.TempDir(), "missing"))
if _, err := readRequiredSecret("TEST_CREATOR_SECRET"); err == nil {
t.Fatal("expected missing secret file to fail")
}
})
}