264 lines
12 KiB
Go
264 lines
12 KiB
Go
package openbao
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"errors"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
|
)
|
|
|
|
func TestEnsureRecoveryIdentity(t *testing.T) {
|
|
if _, err := exec.LookPath("age-keygen"); err != nil {
|
|
t.Skip("age-keygen is required for OpenBao recovery setup")
|
|
}
|
|
recipient, err := EnsureRecoveryIdentity(filepath.Join(t.TempDir(), "recovery-key.txt"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasPrefix(recipient, "age1") {
|
|
t.Fatalf("invalid recovery recipient")
|
|
}
|
|
}
|
|
|
|
func TestUnsealUsesPromptedStdinOnly(t *testing.T) {
|
|
originalExec, originalStatus := execInPod, openBaoStatus
|
|
t.Cleanup(func() { execInPod, openBaoStatus = originalExec, originalStatus })
|
|
calls := 0
|
|
execInPod = func(_ string, input []byte, args ...string) ([]byte, error) {
|
|
if len(input) == 0 || len(args) != 3 || args[0] != "sh" || args[1] != "-ec" || args[2] != "read -r key; bao operator unseal \"$key\" >/dev/null" {
|
|
t.Fatal("unseal share was not submitted through prompted stdin")
|
|
}
|
|
calls++
|
|
return nil, nil
|
|
}
|
|
openBaoStatus = func(string) (status, error) { return status{Initialized: true}, nil }
|
|
if err := unseal("kubeconfig", RecoveryMaterial{UnsealKeysB64: []string{"share-1", "share-2", "share-3"}, UnsealThreshold: 2}); err != nil || calls != 3 {
|
|
t.Fatalf("unseal calls:%d err:%v", calls, err)
|
|
}
|
|
}
|
|
|
|
func TestUnsealFallsBackToControllerSecret(t *testing.T) {
|
|
originalExec, originalStatus, originalController := execInPod, openBaoStatus, execInUnsealController
|
|
t.Cleanup(func() {
|
|
execInPod, openBaoStatus, execInUnsealController = originalExec, originalStatus, originalController
|
|
})
|
|
shares := 0
|
|
execInPod = func(_ string, _ []byte, _ ...string) ([]byte, error) { shares++; return nil, nil }
|
|
statusChecks := 0
|
|
openBaoStatus = func(string) (status, error) {
|
|
statusChecks++
|
|
return status{Initialized: true, Sealed: statusChecks == 1}, nil
|
|
}
|
|
controllerCalled := false
|
|
execInUnsealController = func(_ string, script string) ([]byte, error) {
|
|
controllerCalled = strings.Contains(script, "/unseal/unseal-*")
|
|
return nil, nil
|
|
}
|
|
if err := unseal("kubeconfig", RecoveryMaterial{UnsealKeysB64: []string{"share-1", "share-2", "share-3"}, UnsealThreshold: 2}); err != nil || shares != 3 || !controllerCalled {
|
|
t.Fatalf("unseal fallback = shares:%d controller:%t err:%v", shares, controllerCalled, err)
|
|
}
|
|
}
|
|
|
|
func TestWriteSecretFramesMultilineValues(t *testing.T) {
|
|
original := execInPodMutation
|
|
t.Cleanup(func() { execInPodMutation = original })
|
|
var input, script string
|
|
execInPodMutation = func(_ string, contents []byte, args ...string) ([]byte, error) {
|
|
input, script = string(contents), args[len(args)-1]
|
|
return nil, nil
|
|
}
|
|
value := "tunnel: tunnel\ningress:\n - service: http_status:404\n"
|
|
if err := writeSecret("kubeconfig", "root", "platform/cloudflare-tunnel", map[string]string{"config": value}); err != nil || input != "root\n"+base64.StdEncoding.EncodeToString([]byte(value))+"\n" || !strings.Contains(script, "base64 -d") {
|
|
t.Fatalf("multiline secret boundary was not framed safely: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWriteSecretRedactsMutationFailure(t *testing.T) {
|
|
original := execInPodMutation
|
|
t.Cleanup(func() { execInPodMutation = original })
|
|
const value = "must-not-leak"
|
|
execInPodMutation = func(_ string, _ []byte, _ ...string) ([]byte, error) {
|
|
return []byte(value), errors.New(value)
|
|
}
|
|
err := writeSecret("kubeconfig", "root-token", "cicd/demo", map[string]string{"password": value})
|
|
if err == nil || err.Error() != `write OpenBao secret "cicd/demo"` || strings.Contains(err.Error(), value) {
|
|
t.Fatalf("secret write error leaked a value: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRefreshExternalSecretsIsReadyGatedAndScoped(t *testing.T) {
|
|
original := kubectlOutput
|
|
t.Cleanup(func() { kubectlOutput = original })
|
|
var calls []string
|
|
kubectlOutput = func(_ string, args ...string) ([]byte, error) {
|
|
calls = append(calls, strings.Join(args, " "))
|
|
if len(calls) == 1 {
|
|
return []byte("True"), nil
|
|
}
|
|
if len(calls) == 3 {
|
|
return []byte("externalsecret.external-secrets.io/forgejo-webhook"), nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
if err := refreshExternalSecrets("kubeconfig"); err != nil || len(calls) != 4 || !strings.Contains(calls[0], "get deployment/external-secrets") || !strings.Contains(calls[1], "annotate clustersecretstore openbao") || !strings.Contains(calls[2], "get externalsecret forgejo-webhook") || !strings.Contains(calls[3], "annotate externalsecret forgejo-webhook") || strings.Contains(calls[1], "--all") || strings.Contains(calls[3], "--all") {
|
|
t.Fatalf("ExternalSecret refresh was not readiness-gated and scoped: %q, %v", calls, err)
|
|
}
|
|
}
|
|
|
|
func TestRefreshExternalSecretsSkipsWebhookBeforeTekton(t *testing.T) {
|
|
original := kubectlOutput
|
|
t.Cleanup(func() { kubectlOutput = original })
|
|
var calls []string
|
|
kubectlOutput = func(_ string, args ...string) ([]byte, error) {
|
|
calls = append(calls, strings.Join(args, " "))
|
|
if len(calls) == 1 {
|
|
return []byte("True"), nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
if err := refreshExternalSecrets("kubeconfig"); err != nil || len(calls) != 3 || !strings.Contains(calls[2], "--ignore-not-found") {
|
|
t.Fatalf("missing webhook ExternalSecret was not safely skipped: %q, %v", calls, err)
|
|
}
|
|
}
|
|
|
|
func TestConfigureSecretGrantsScopesApplicationAndSharedPaths(t *testing.T) {
|
|
originalDecrypt, originalExec := decryptRecovery, execInPodMutation
|
|
t.Cleanup(func() { decryptRecovery, execInPodMutation = originalDecrypt, originalExec })
|
|
decryptRecovery = func(_, _ string) ([]byte, error) {
|
|
return []byte(`{"unseal_keys_b64":["share"],"unseal_threshold":1,"root_token":"root"}`), nil
|
|
}
|
|
var script string
|
|
execInPodMutation = func(_ string, input []byte, args ...string) ([]byte, error) {
|
|
if string(input) != "root\n" || len(args) != 3 || args[0] != "sh" || args[1] != "-ec" {
|
|
t.Fatal("secret grant did not use root token through stdin")
|
|
}
|
|
script = args[2]
|
|
return nil, nil
|
|
}
|
|
grants := []config.SecretGrant{
|
|
{Application: "orders-api", Consumer: "publish", Secrets: []string{"registry"}, Shared: []string{"artifact-cache"}},
|
|
{Application: "orders-api", Consumer: "runtime", Environment: "production", Secrets: []string{"database"}, Shared: []string{"rabbitmq"}},
|
|
}
|
|
if err := ConfigureSecretGrants("kubeconfig", "identity", "bundle", grants); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, want := range []string{
|
|
`secret/data/apps/orders-api/registry`,
|
|
`secret/data/apps/orders-api/database`,
|
|
`secret/data/shared/artifact-cache/*`,
|
|
`secret/data/shared/rabbitmq/*`,
|
|
`bound_service_account_names=maidn-orders-api-publish`,
|
|
`bound_service_account_namespaces=orders-api-production`,
|
|
} {
|
|
if !strings.Contains(script, want) {
|
|
t.Fatalf("secret grant script missing %q: %s", want, script)
|
|
}
|
|
}
|
|
if strings.Contains(script, `secret/data/*`) {
|
|
t.Fatal("secret grant widened access to every OpenBao secret")
|
|
}
|
|
if strings.Contains(script, `secret/data/apps/orders-api/registry/*`) || strings.Contains(script, `secret/data/apps/orders-api/database/*`) {
|
|
t.Fatal("secret grant widened access beyond declared application secrets")
|
|
}
|
|
}
|
|
|
|
func TestConfigureSecretGrantsPreservesRedactedPolicyDiagnostics(t *testing.T) {
|
|
originalDecrypt, originalExec := decryptRecovery, execInPodMutation
|
|
t.Cleanup(func() { decryptRecovery, execInPodMutation = originalDecrypt, originalExec })
|
|
const rootToken = "must-not-leak"
|
|
decryptRecovery = func(_, _ string) ([]byte, error) {
|
|
return []byte(`{"unseal_keys_b64":["share"],"unseal_threshold":1,"root_token":"must-not-leak"}`), nil
|
|
}
|
|
execInPodMutation = func(_ string, _ []byte, _ ...string) ([]byte, error) {
|
|
return []byte("policy write denied for " + rootToken), errors.New("exit status 1")
|
|
}
|
|
err := ConfigureSecretGrants("kubeconfig", "identity", "bundle", []config.SecretGrant{{Application: "orders-api", Consumer: "publish", Secrets: []string{"registry"}}})
|
|
if err == nil || !strings.Contains(err.Error(), "policy write denied") || strings.Contains(err.Error(), rootToken) {
|
|
t.Fatalf("policy diagnostics were not useful and redacted: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProvisionAppSecretIdentitiesScopesFixtureWithoutRootLeak(t *testing.T) {
|
|
originalDecrypt, originalMutation := decryptRecovery, execInPodMutation
|
|
t.Cleanup(func() { decryptRecovery, execInPodMutation = originalDecrypt, originalMutation })
|
|
decryptRecovery = func(string, string) ([]byte, error) {
|
|
return []byte(`{"root_token":"root-token","unseal_keys_b64":["share"],"unseal_threshold":1}`), nil
|
|
}
|
|
execInPodMutation = func(_ string, input []byte, args ...string) ([]byte, error) {
|
|
command := strings.Join(args, " ")
|
|
if string(input) != "root-token\n" || !strings.Contains(command, `secret/data/apps/maidn-e2e-web/E2E_PROBE`) || strings.Contains(command, `secret/data/apps/maidn-e2e-web/*`) {
|
|
t.Fatal("fixture identity policy scope is incorrect")
|
|
}
|
|
return []byte("eyJhdXRoIjp7ImNsaWVudF90b2tlbiI6ImFkbWluLXRva2VuIn19\neyJhdXRoIjp7ImNsaWVudF90b2tlbiI6ImUyZS10b2tlbiJ9fQ==\n"), nil
|
|
}
|
|
tokens, err := ProvisionAppSecretIdentities("kubeconfig", "identity", "bundle", "maidn-e2e-web")
|
|
if err != nil || tokens.Admin != "admin-token" || tokens.E2E != "e2e-token" {
|
|
t.Fatalf("ProvisionAppSecretIdentities() = %#v, %v", tokens, err)
|
|
}
|
|
}
|
|
|
|
func TestConfigureKubernetesAuthLimitsPlatformStore(t *testing.T) {
|
|
original := execInPodMutation
|
|
t.Cleanup(func() { execInPodMutation = original })
|
|
var script string
|
|
execInPodMutation = func(_ string, _ []byte, args ...string) ([]byte, error) {
|
|
script = args[len(args)-1]
|
|
return nil, nil
|
|
}
|
|
if err := configureKubernetesAuth("kubeconfig", "root", "reviewer"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, want := range []string{`secret/data/platform/*`, `secret/data/cicd/*`} {
|
|
if !strings.Contains(script, want) {
|
|
t.Fatalf("platform policy missing %q", want)
|
|
}
|
|
}
|
|
if strings.Contains(script, `secret/data/*`) {
|
|
t.Fatal("platform External Secrets role can read every secret")
|
|
}
|
|
}
|
|
|
|
func TestOpenBaoMutationTimeoutIsSeparateFromProbeTimeout(t *testing.T) {
|
|
if commandTimeout != time.Minute || openBaoMutationTimeout != 5*time.Minute {
|
|
t.Fatalf("probe timeout %s, mutation timeout %s", commandTimeout, openBaoMutationTimeout)
|
|
}
|
|
}
|
|
|
|
func TestReadRecoveryMaterialDecryptsAndValidatesBundle(t *testing.T) {
|
|
original := decryptRecovery
|
|
t.Cleanup(func() { decryptRecovery = original })
|
|
called := false
|
|
decryptRecovery = func(identityPath, bundlePath string) ([]byte, error) {
|
|
called = identityPath == "recovery-identity" && bundlePath == "recovery-bundle"
|
|
return []byte(`{"unseal_keys_b64":["test-share-1","test-share-2"],"unseal_threshold":2,"root_token":"test-root"}`), nil
|
|
}
|
|
|
|
material, err := ReadRecoveryMaterial("recovery-identity", "recovery-bundle")
|
|
if err != nil || !called || material.UnsealThreshold != 2 || len(material.UnsealKeysB64) != 2 || material.RootToken == "" {
|
|
t.Fatal("valid OpenBao recovery material was not read")
|
|
}
|
|
}
|
|
|
|
func TestReadRecoveryMaterialRejectsMalformedOrInsufficientBundle(t *testing.T) {
|
|
original := decryptRecovery
|
|
t.Cleanup(func() { decryptRecovery = original })
|
|
for _, plaintext := range [][]byte{
|
|
[]byte(`{"unseal_keys_b64":`),
|
|
[]byte(`{"unseal_keys_b64":["test-share"],"unseal_threshold":2,"root_token":"test-root"}`),
|
|
} {
|
|
decryptRecovery = func(string, string) ([]byte, error) { return plaintext, nil }
|
|
if _, err := ReadRecoveryMaterial("recovery-identity", "recovery-bundle"); err == nil {
|
|
t.Fatal("invalid OpenBao recovery material was accepted")
|
|
}
|
|
}
|
|
decryptRecovery = func(string, string) ([]byte, error) { return nil, errors.New("unavailable") }
|
|
if _, err := ReadRecoveryMaterial("recovery-identity", "recovery-bundle"); err == nil {
|
|
t.Fatal("recovery decryption failure was accepted")
|
|
}
|
|
}
|