From 9d1277e5ba9a15243de7edf99653b2e3fcad2274 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 25 Jul 2026 10:21:24 +0800 Subject: [PATCH 1/4] feat(auth): manage email hook signing secret --- api/v1alpha1/supabaseproject_types.go | 5 ++ .../supabase.guion.dev_supabaseprojects.yaml | 5 ++ internal/controller/email_hook_secret_test.go | 64 +++++++++++++++++++ .../controller/supabaseproject_controller.go | 44 ++++++++++++- internal/resources/deployments/auth.go | 12 ++++ internal/resources/deployments/auth_test.go | 57 +++++++++++++++++ internal/resources/secrets/secrets.go | 41 ++++++++++++ internal/resources/secrets/secrets_test.go | 23 +++++++ 8 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 internal/controller/email_hook_secret_test.go create mode 100644 internal/resources/deployments/auth_test.go diff --git a/api/v1alpha1/supabaseproject_types.go b/api/v1alpha1/supabaseproject_types.go index fcc85e4..f0d3d54 100644 --- a/api/v1alpha1/supabaseproject_types.go +++ b/api/v1alpha1/supabaseproject_types.go @@ -745,6 +745,11 @@ type SecretNamesStatus struct { // +optional AuthAdmin string `json:"authAdmin,omitempty"` + // EmailHook is the name of the send-email webhook signing secret. + // The secret contains the fixed key 'secret'. + // +optional + EmailHook string `json:"emailHook,omitempty"` + // PowersyncStoragePassword is the name of the powersync_storage role password secret // +optional PowersyncStoragePassword string `json:"powersyncStoragePassword,omitempty"` diff --git a/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml b/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml index 418332d..97f1c11 100644 --- a/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml +++ b/config/crd/bases/supabase.guion.dev_supabaseprojects.yaml @@ -1544,6 +1544,11 @@ spec: description: Authenticator is the name of the authenticator password secret type: string + emailHook: + description: |- + EmailHook is the name of the send-email webhook signing secret. + The secret contains the fixed key 'secret'. + type: string jwt: description: JWT is the name of the JWT secret type: string diff --git a/internal/controller/email_hook_secret_test.go b/internal/controller/email_hook_secret_test.go new file mode 100644 index 0000000..2eb9d3d --- /dev/null +++ b/internal/controller/email_hook_secret_test.go @@ -0,0 +1,64 @@ +package controller + +import ( + "context" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" +) + +func TestReconcileSecretsCreatesAndPreservesEmailHookSecret(t *testing.T) { + t.Parallel() + + scheme := newIdempotencyTestScheme(t) + project := &supabasev1alpha1.SupabaseProject{ + TypeMeta: metav1.TypeMeta{APIVersion: supabasev1alpha1.GroupVersion.String(), Kind: "SupabaseProject"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "app", Namespace: "default", UID: "project-uid", + }, + Spec: supabasev1alpha1.SupabaseProjectSpec{Auth: supabasev1alpha1.AuthSpec{ + EmailHook: &supabasev1alpha1.EmailHookSpec{Enabled: true, URI: "https://email.example.com/auth"}, + }}, + } + objects := []client.Object{project} + for _, name := range []string{"app-jwt", "app-supabase-admin-password", "app-authenticator-password", "app-auth-admin-password"} { + objects = append(objects, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}}) + } + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(project).WithObjects(objects...).Build() + reconciler := &SupabaseProjectReconciler{Client: kubeClient, Scheme: scheme} + + if err := reconciler.reconcileSecrets(context.Background(), project); err != nil { + t.Fatal(err) + } + + created := &corev1.Secret{} + key := client.ObjectKey{Name: "app-email-hook", Namespace: "default"} + if err := kubeClient.Get(context.Background(), key, created); err != nil { + t.Fatalf("get generated email hook secret: %v", err) + } + value := string(created.Data["secret"]) + if !strings.HasPrefix(value, "v1,whsec_") { + t.Fatal("generated secret does not use the Standard Webhooks format") + } + if project.Status.SecretNames.EmailHook != "app-email-hook" { + t.Fatalf("status emailHook = %q", project.Status.SecretNames.EmailHook) + } + + if err := reconciler.reconcileSecrets(context.Background(), project); err != nil { + t.Fatal(err) + } + preserved := &corev1.Secret{} + if err := kubeClient.Get(context.Background(), key, preserved); err != nil { + t.Fatal(err) + } + preservedValue := string(preserved.Data["secret"]) + if preservedValue != value { + t.Fatal("email hook secret rotated during reconciliation") + } +} diff --git a/internal/controller/supabaseproject_controller.go b/internal/controller/supabaseproject_controller.go index 41df8cb..fba8f49 100644 --- a/internal/controller/supabaseproject_controller.go +++ b/internal/controller/supabaseproject_controller.go @@ -288,11 +288,49 @@ func (r *SupabaseProjectReconciler) reconcileSecrets(ctx context.Context, projec // Check if user-specified secrets mode is enabled if project.Spec.Secrets != nil && !project.Spec.Secrets.AutoGenerate { - return r.reconcileUserSpecifiedSecrets(ctx, project) + if err := r.reconcileUserSpecifiedSecrets(ctx, project); err != nil { + return err + } + } else { + // Auto-generate mode: check if secrets already exist in the cluster. + if err := r.reconcileAutoGeneratedSecrets(ctx, project); err != nil { + return err + } } - // Auto-generate mode: check if secrets already exist in the cluster - return r.reconcileAutoGeneratedSecrets(ctx, project) + return r.reconcileEmailHookSecret(ctx, project) +} + +func (r *SupabaseProjectReconciler) reconcileEmailHookSecret(ctx context.Context, project *supabasev1alpha1.SupabaseProject) error { + hook := project.Spec.Auth.EmailHook + if hook == nil || !hook.Enabled { + if project.Status.SecretNames.EmailHook == "" { + return nil + } + project.Status.SecretNames.EmailHook = "" + return r.updateProjectStatus(ctx, project) + } + + generated, name, err := secrets.GenerateEmailHookSecret(project) + if err != nil { + return err + } + + existing := &corev1.Secret{} + err = r.Get(ctx, types.NamespacedName{Name: name, Namespace: project.Namespace}, existing) + if err != nil { + if !apierrors.IsNotFound(err) { + return err + } + if err := r.createOrUpdateSecret(ctx, project, generated); err != nil { + return err + } + } else if err := secrets.ValidateEmailHookSecret(existing); err != nil { + return err + } + + project.Status.SecretNames.EmailHook = name + return r.updateProjectStatus(ctx, project) } // reconcileUserSpecifiedSecrets validates and uses user-provided secrets diff --git a/internal/resources/deployments/auth.go b/internal/resources/deployments/auth.go index f57ba9e..3a66f0d 100644 --- a/internal/resources/deployments/auth.go +++ b/internal/resources/deployments/auth.go @@ -27,6 +27,7 @@ import ( "github.com/GuionAI/cloudnative-supabase/internal/resources/cnpg" "github.com/GuionAI/cloudnative-supabase/internal/resources/common" "github.com/GuionAI/cloudnative-supabase/internal/resources/defaults" + secretresources "github.com/GuionAI/cloudnative-supabase/internal/resources/secrets" ) const ( @@ -221,9 +222,20 @@ func buildAuthEnv(project *supabasev1alpha1.SupabaseProject, secretNames *supaba // Add email hook configuration if spec.EmailHook != nil && spec.EmailHook.Enabled { + emailHookSecretName := secretNames.EmailHook + if emailHookSecretName == "" { + emailHookSecretName = secretresources.EmailHookSecretName(project) + } env = append(env, corev1.EnvVar{Name: "GOTRUE_HOOK_SEND_EMAIL_ENABLED", Value: "true"}, corev1.EnvVar{Name: "GOTRUE_HOOK_SEND_EMAIL_URI", Value: spec.EmailHook.URI}, + corev1.EnvVar{ + Name: "GOTRUE_HOOK_SEND_EMAIL_SECRETS", + ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: emailHookSecretName}, + Key: "secret", + }}, + }, ) } diff --git a/internal/resources/deployments/auth_test.go b/internal/resources/deployments/auth_test.go new file mode 100644 index 0000000..9a4c5ac --- /dev/null +++ b/internal/resources/deployments/auth_test.go @@ -0,0 +1,57 @@ +package deployments + +import ( + "testing" + + supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" +) + +func TestAuthEmailHookUsesGeneratedSigningSecret(t *testing.T) { + project := newTestProject(testNamespace) + project.Spec.Auth.EmailHook = &supabasev1alpha1.EmailHookSpec{ + Enabled: true, + URI: "https://email.sliqs.app/api/v1/supabase/auth", + } + secretNames := newTestSecretNames() + secretNames.EmailHook = "my-app-email-hook" + + deployment := BuildAuthDeployment(project, secretNames) + env := deployment.Spec.Template.Spec.Containers[0].Env + + wantValues := map[string]string{ + "GOTRUE_HOOK_SEND_EMAIL_ENABLED": "true", + "GOTRUE_HOOK_SEND_EMAIL_URI": "https://email.sliqs.app/api/v1/supabase/auth", + } + for name, want := range wantValues { + found := false + for _, variable := range env { + if variable.Name == name { + found = true + if variable.Value != want { + t.Fatalf("%s = %q, want %q", name, variable.Value, want) + } + } + } + if !found { + t.Fatalf("missing %s", name) + } + } + + foundSecret := false + for _, variable := range env { + if variable.Name != "GOTRUE_HOOK_SEND_EMAIL_SECRETS" { + continue + } + foundSecret = true + if variable.ValueFrom == nil || variable.ValueFrom.SecretKeyRef == nil { + t.Fatal("email hook secret is not sourced from a Secret") + } + ref := variable.ValueFrom.SecretKeyRef + if ref.Name != "my-app-email-hook" || ref.Key != "secret" { + t.Fatalf("email hook secret ref = %s/%s", ref.Name, ref.Key) + } + } + if !foundSecret { + t.Fatal("missing GOTRUE_HOOK_SEND_EMAIL_SECRETS") + } +} diff --git a/internal/resources/secrets/secrets.go b/internal/resources/secrets/secrets.go index 94c021b..86fb79e 100644 --- a/internal/resources/secrets/secrets.go +++ b/internal/resources/secrets/secrets.go @@ -18,6 +18,7 @@ package secrets import ( "fmt" + "strings" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -33,6 +34,9 @@ var RequiredJWTKeys = []string{"secret", "anonKey", "serviceKey"} // RequiredRoleKeys are the required keys in a database role secret var RequiredRoleKeys = []string{"username", "password"} +// EmailHookSecretKey is the fixed key containing the Standard Webhooks secret. +const EmailHookSecretKey = "secret" + // GenerateSecrets generates all required secrets for a SupabaseProject func GenerateSecrets(project *supabasev1alpha1.SupabaseProject) ([]*corev1.Secret, supabasev1alpha1.SecretNamesStatus, error) { var secrets []*corev1.Secret @@ -143,6 +147,43 @@ func generateRoleSecret(project *supabasev1alpha1.SupabaseProject, nameSuffix, u return secret, secretName, nil } +// GenerateEmailHookSecret creates the per-project Standard Webhooks signing secret. +func GenerateEmailHookSecret(project *supabasev1alpha1.SupabaseProject) (*corev1.Secret, string, error) { + secretName := EmailHookSecretName(project) + value, err := crypto.GenerateWebhookSecret() + if err != nil { + return nil, "", fmt.Errorf("generating webhook secret: %w", err) + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: project.Namespace, + Labels: common.ComponentLabels(project, "email-hook"), + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + EmailHookSecretKey: []byte(value), + }, + } + + return secret, secretName, nil +} + +// EmailHookSecretName returns the fixed per-project email-hook secret name. +func EmailHookSecretName(project *supabasev1alpha1.SupabaseProject) string { + return project.Name + "-email-hook" +} + +// ValidateEmailHookSecret validates the fixed email-hook secret contract. +func ValidateEmailHookSecret(secret *corev1.Secret) error { + value := string(secret.Data[EmailHookSecretKey]) + if !strings.HasPrefix(value, "v1,whsec_") { + return fmt.Errorf("email hook secret %s/%s missing valid key: %s", secret.Namespace, secret.Name, EmailHookSecretKey) + } + return nil +} + // ValidateJWTSecret validates that a secret contains all required JWT keys func ValidateJWTSecret(secret *corev1.Secret) error { for _, key := range RequiredJWTKeys { diff --git a/internal/resources/secrets/secrets_test.go b/internal/resources/secrets/secrets_test.go index 79558b6..89c300f 100644 --- a/internal/resources/secrets/secrets_test.go +++ b/internal/resources/secrets/secrets_test.go @@ -1,6 +1,7 @@ package secrets import ( + "strings" "testing" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -8,6 +9,28 @@ import ( supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" ) +func TestGenerateEmailHookSecretUsesFixedContract(t *testing.T) { + project := newTestProject("sliqs-dev") + + secret, name, err := GenerateEmailHookSecret(project) + if err != nil { + t.Fatalf("GenerateEmailHookSecret() error = %v", err) + } + if name != "my-app-email-hook" { + t.Fatalf("name = %q, want %q", name, "my-app-email-hook") + } + if secret.Name != name || secret.Namespace != "sliqs-dev" { + t.Fatalf("secret metadata = %s/%s, want sliqs-dev/%s", secret.Namespace, secret.Name, name) + } + value := string(secret.Data["secret"]) + if !strings.HasPrefix(value, "v1,whsec_") { + t.Fatalf("secret value has unexpected format") + } + if len(secret.Data) != 1 { + t.Fatalf("secret keys = %d, want 1", len(secret.Data)) + } +} + func newTestProject(namespace string) *supabasev1alpha1.SupabaseProject { return &supabasev1alpha1.SupabaseProject{ ObjectMeta: metav1.ObjectMeta{Name: "my-app", Namespace: namespace}, From 45b78be07b495b27d4695f2d82712d7f61286477 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 25 Jul 2026 10:35:14 +0800 Subject: [PATCH 2/4] chore(crd): sync email hook status schema --- .../crds/supabase.guion.dev_supabaseprojects.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml b/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml index 418332d..97f1c11 100644 --- a/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml +++ b/charts/cloudnative-supabase/crds/supabase.guion.dev_supabaseprojects.yaml @@ -1544,6 +1544,11 @@ spec: description: Authenticator is the name of the authenticator password secret type: string + emailHook: + description: |- + EmailHook is the name of the send-email webhook signing secret. + The secret contains the fixed key 'secret'. + type: string jwt: description: JWT is the name of the JWT secret type: string From f47e1d13bc469ecb1ba67b7ff3db8c45b0ab813d Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 25 Jul 2026 12:00:06 +0800 Subject: [PATCH 3/4] fix(auth): harden email hook secret reconciliation --- internal/controller/email_hook_secret_test.go | 113 ++++++++++++++++++ .../controller/supabaseproject_controller.go | 50 ++++++-- internal/resources/secrets/secrets.go | 7 ++ internal/resources/secrets/secrets_test.go | 30 +++++ 4 files changed, 193 insertions(+), 7 deletions(-) diff --git a/internal/controller/email_hook_secret_test.go b/internal/controller/email_hook_secret_test.go index 2eb9d3d..bdcd41e 100644 --- a/internal/controller/email_hook_secret_test.go +++ b/internal/controller/email_hook_secret_test.go @@ -1,10 +1,13 @@ package controller import ( + "bytes" "context" + "encoding/base64" "strings" "testing" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" @@ -62,3 +65,113 @@ func TestReconcileSecretsCreatesAndPreservesEmailHookSecret(t *testing.T) { t.Fatal("email hook secret rotated during reconciliation") } } + +func TestBaseSecretReconciliationPreservesEmailHookStatus(t *testing.T) { + t.Parallel() + + for _, mode := range []string{"auto", "user-specified"} { + t.Run(mode, func(t *testing.T) { + t.Parallel() + + scheme := newIdempotencyTestScheme(t) + project := &supabasev1alpha1.SupabaseProject{ + TypeMeta: metav1.TypeMeta{APIVersion: supabasev1alpha1.GroupVersion.String(), Kind: "SupabaseProject"}, + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "default", UID: "project-uid"}, + Status: supabasev1alpha1.SupabaseProjectStatus{SecretNames: supabasev1alpha1.SecretNamesStatus{ + EmailHook: "app-email-hook", + }}, + } + objects := []client.Object{project} + + if mode == "user-specified" { + project.Spec.Secrets = &supabasev1alpha1.SecretsSpec{ + AutoGenerate: false, + JWT: "jwt", SupabaseAdmin: "supabase-admin", Authenticator: "authenticator", AuthAdmin: "auth-admin", + } + objects = append(objects, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "jwt", Namespace: "default"}, Data: map[string][]byte{"secret": {}, "anonKey": {}, "serviceKey": {}}}, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "supabase-admin", Namespace: "default"}, Data: map[string][]byte{"username": {}, "password": {}}}, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "authenticator", Namespace: "default"}, Data: map[string][]byte{"username": {}, "password": {}}}, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "auth-admin", Namespace: "default"}, Data: map[string][]byte{"username": {}, "password": {}}}, + ) + } else { + for _, name := range []string{"app-jwt", "app-supabase-admin-password", "app-authenticator-password", "app-auth-admin-password"} { + objects = append(objects, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}}) + } + } + + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(project).WithObjects(objects...).Build() + reconciler := &SupabaseProjectReconciler{Client: kubeClient, Scheme: scheme} + var err error + if mode == "user-specified" { + err = reconciler.reconcileUserSpecifiedSecrets(context.Background(), project) + } else { + err = reconciler.reconcileAutoGeneratedSecrets(context.Background(), project) + } + if err != nil { + t.Fatal(err) + } + if project.Status.SecretNames.EmailHook != "app-email-hook" { + t.Fatalf("status emailHook = %q, want app-email-hook", project.Status.SecretNames.EmailHook) + } + }) + } +} + +func TestReconcileAuthRollsDeploymentWhenEmailHookSecretChanges(t *testing.T) { + t.Parallel() + + scheme := newIdempotencyTestScheme(t) + project := &supabasev1alpha1.SupabaseProject{ + TypeMeta: metav1.TypeMeta{APIVersion: supabasev1alpha1.GroupVersion.String(), Kind: "SupabaseProject"}, + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "default", UID: "project-uid"}, + Spec: supabasev1alpha1.SupabaseProjectSpec{Auth: supabasev1alpha1.AuthSpec{ + EmailHook: &supabasev1alpha1.EmailHookSpec{Enabled: true, URI: "https://email.example.com/auth"}, + }}, + } + secretNames := &supabasev1alpha1.SecretNamesStatus{ + JWT: "jwt", SupabaseAdmin: "supabase-admin", Authenticator: "authenticator", AuthAdmin: "auth-admin", EmailHook: "email-hook", + } + hookSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "email-hook", Namespace: "default"}, + Data: map[string][]byte{"secret": emailHookSecretValue(1)}, + } + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(project, hookSecret).Build() + reconciler := &SupabaseProjectReconciler{Client: kubeClient, Scheme: scheme} + ctx := context.Background() + + if err := reconciler.reconcileAuth(ctx, project, secretNames); err != nil { + t.Fatal(err) + } + deployment := &appsv1.Deployment{} + key := client.ObjectKey{Name: "app-auth", Namespace: "default"} + if err := kubeClient.Get(ctx, key, deployment); err != nil { + t.Fatal(err) + } + firstHash := deployment.Spec.Template.Annotations["supabase.guion.dev/email-hook-secret-hash"] + if firstHash == "" { + t.Fatal("auth pod template is missing the email-hook secret hash annotation") + } + + if err := kubeClient.Get(ctx, client.ObjectKeyFromObject(hookSecret), hookSecret); err != nil { + t.Fatal(err) + } + hookSecret.Data["secret"] = emailHookSecretValue(2) + if err := kubeClient.Update(ctx, hookSecret); err != nil { + t.Fatal(err) + } + if err := reconciler.reconcileAuth(ctx, project, secretNames); err != nil { + t.Fatal(err) + } + if err := kubeClient.Get(ctx, key, deployment); err != nil { + t.Fatal(err) + } + secondHash := deployment.Spec.Template.Annotations["supabase.guion.dev/email-hook-secret-hash"] + if secondHash == firstHash { + t.Fatal("auth pod template hash did not change after the email-hook secret rotated") + } +} + +func emailHookSecretValue(fill byte) []byte { + return []byte("v1,whsec_" + base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{fill}, 32))) +} diff --git a/internal/controller/supabaseproject_controller.go b/internal/controller/supabaseproject_controller.go index fba8f49..ad11b5a 100644 --- a/internal/controller/supabaseproject_controller.go +++ b/internal/controller/supabaseproject_controller.go @@ -57,6 +57,8 @@ import ( const ( // RequeueDelay is the delay before requeueing when waiting for resources RequeueDelay = 10 * time.Second + + emailHookSecretHashAnnotation = "supabase.guion.dev/email-hook-secret-hash" ) // serviceReconcileConfig holds configuration for reconciling a service component. @@ -311,17 +313,17 @@ func (r *SupabaseProjectReconciler) reconcileEmailHookSecret(ctx context.Context return r.updateProjectStatus(ctx, project) } - generated, name, err := secrets.GenerateEmailHookSecret(project) - if err != nil { - return err - } - + name := secrets.EmailHookSecretName(project) existing := &corev1.Secret{} - err = r.Get(ctx, types.NamespacedName{Name: name, Namespace: project.Namespace}, existing) + err := r.Get(ctx, types.NamespacedName{Name: name, Namespace: project.Namespace}, existing) if err != nil { if !apierrors.IsNotFound(err) { return err } + generated, _, err := secrets.GenerateEmailHookSecret(project) + if err != nil { + return err + } if err := r.createOrUpdateSecret(ctx, project, generated); err != nil { return err } @@ -339,6 +341,7 @@ func (r *SupabaseProjectReconciler) reconcileUserSpecifiedSecrets(ctx context.Co log.Info("Using user-specified secrets") secretNames := secrets.GetSecretNamesFromSpec(project.Spec.Secrets) + secretNames.EmailHook = project.Status.SecretNames.EmailHook // Validate JWT secret exists and has required keys jwtSecret := &corev1.Secret{} @@ -427,6 +430,7 @@ func (r *SupabaseProjectReconciler) reconcileAutoGeneratedSecrets(ctx context.Co SupabaseAdmin: project.Name + "-supabase-admin-password", Authenticator: project.Name + "-authenticator-password", AuthAdmin: project.Name + "-auth-admin-password", + EmailHook: project.Status.SecretNames.EmailHook, } allExist := true @@ -472,6 +476,7 @@ func (r *SupabaseProjectReconciler) reconcileAutoGeneratedSecrets(ctx context.Co } return err } + secretNames.EmailHook = project.Status.SecretNames.EmailHook // Create or update each secret for _, secret := range generatedSecrets { @@ -734,10 +739,14 @@ func (r *SupabaseProjectReconciler) reconcileServiceComponent(ctx context.Contex // reconcileAuth deploys the Auth service func (r *SupabaseProjectReconciler) reconcileAuth(ctx context.Context, project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus) error { + deployment, err := r.buildAuthDeployment(ctx, project, secretNames) + if err != nil { + return err + } config := newServiceReconcileConfig( "Auth", supabasev1alpha1.ConditionTypeAuthReady, - func() *appsv1.Deployment { return deployments.BuildAuthDeployment(project, secretNames) }, + func() *appsv1.Deployment { return deployment }, func() *corev1.Service { return services.BuildAuthService(project) }, func(ready bool) { project.Status.Services.Auth = supabasev1alpha1.ServiceStatus{Ready: ready} }, ) @@ -750,6 +759,33 @@ func (r *SupabaseProjectReconciler) reconcileAuth(ctx context.Context, project * return r.reconcileServiceComponent(ctx, project, config) } +func (r *SupabaseProjectReconciler) buildAuthDeployment(ctx context.Context, project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus) (*appsv1.Deployment, error) { + deployment := deployments.BuildAuthDeployment(project, secretNames) + hook := project.Spec.Auth.EmailHook + if hook == nil || !hook.Enabled { + return deployment, nil + } + + secretName := secretNames.EmailHook + if secretName == "" { + secretName = secrets.EmailHookSecretName(project) + } + hookSecret := &corev1.Secret{} + if err := r.Get(ctx, types.NamespacedName{Name: secretName, Namespace: project.Namespace}, hookSecret); err != nil { + return nil, fmt.Errorf("getting email hook secret %s: %w", secretName, err) + } + if err := secrets.ValidateEmailHookSecret(hookSecret); err != nil { + return nil, err + } + + hash := sha256.Sum256(hookSecret.Data[secrets.EmailHookSecretKey]) + if deployment.Spec.Template.Annotations == nil { + deployment.Spec.Template.Annotations = make(map[string]string) + } + deployment.Spec.Template.Annotations[emailHookSecretHashAnnotation] = hex.EncodeToString(hash[:]) + return deployment, nil +} + // reconcileRest deploys the REST service func (r *SupabaseProjectReconciler) reconcileRest(ctx context.Context, project *supabasev1alpha1.SupabaseProject, secretNames *supabasev1alpha1.SecretNamesStatus) error { config := newServiceReconcileConfig( diff --git a/internal/resources/secrets/secrets.go b/internal/resources/secrets/secrets.go index 86fb79e..3f1b5df 100644 --- a/internal/resources/secrets/secrets.go +++ b/internal/resources/secrets/secrets.go @@ -17,6 +17,7 @@ limitations under the License. package secrets import ( + "encoding/base64" "fmt" "strings" @@ -37,6 +38,8 @@ var RequiredRoleKeys = []string{"username", "password"} // EmailHookSecretKey is the fixed key containing the Standard Webhooks secret. const EmailHookSecretKey = "secret" +const emailHookSecretBytes = 32 + // GenerateSecrets generates all required secrets for a SupabaseProject func GenerateSecrets(project *supabasev1alpha1.SupabaseProject) ([]*corev1.Secret, supabasev1alpha1.SecretNamesStatus, error) { var secrets []*corev1.Secret @@ -181,6 +184,10 @@ func ValidateEmailHookSecret(secret *corev1.Secret) error { if !strings.HasPrefix(value, "v1,whsec_") { return fmt.Errorf("email hook secret %s/%s missing valid key: %s", secret.Namespace, secret.Name, EmailHookSecretKey) } + payload, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(value, "v1,whsec_")) + if err != nil || len(payload) != emailHookSecretBytes { + return fmt.Errorf("email hook secret %s/%s has invalid Standard Webhooks value", secret.Namespace, secret.Name) + } return nil } diff --git a/internal/resources/secrets/secrets_test.go b/internal/resources/secrets/secrets_test.go index 89c300f..f9e6144 100644 --- a/internal/resources/secrets/secrets_test.go +++ b/internal/resources/secrets/secrets_test.go @@ -1,9 +1,12 @@ package secrets import ( + "bytes" + "encoding/base64" "strings" "testing" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1" @@ -31,6 +34,33 @@ func TestGenerateEmailHookSecretUsesFixedContract(t *testing.T) { } } +func TestValidateEmailHookSecretRejectsMalformedValues(t *testing.T) { + t.Parallel() + + validValue := "v1,whsec_" + base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{1}, 32)) + tests := map[string]string{ + "empty payload": "v1,whsec_", + "invalid base64": "v1,whsec_not-base64", + "short payload": "v1,whsec_" + base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{1}, 31)), + "wrong prefix": strings.TrimPrefix(validValue, "v1,"), + } + + for name, value := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + secret := &corev1.Secret{Data: map[string][]byte{EmailHookSecretKey: []byte(value)}} + if err := ValidateEmailHookSecret(secret); err == nil { + t.Fatalf("ValidateEmailHookSecret() accepted %q", value) + } + }) + } + + valid := &corev1.Secret{Data: map[string][]byte{EmailHookSecretKey: []byte(validValue)}} + if err := ValidateEmailHookSecret(valid); err != nil { + t.Fatalf("ValidateEmailHookSecret() rejected a valid secret: %v", err) + } +} + func newTestProject(namespace string) *supabasev1alpha1.SupabaseProject { return &supabasev1alpha1.SupabaseProject{ ObjectMeta: metav1.ObjectMeta{Name: "my-app", Namespace: namespace}, From dbdfebd9f019f3a0d767fa365d6fc20562ca433e Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 25 Jul 2026 12:04:19 +0800 Subject: [PATCH 4/4] fix(auth): report email hook secret readiness --- internal/controller/email_hook_secret_test.go | 51 +++++++++++++++++++ .../controller/supabaseproject_controller.go | 33 +++++++++--- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/internal/controller/email_hook_secret_test.go b/internal/controller/email_hook_secret_test.go index bdcd41e..ceb5652 100644 --- a/internal/controller/email_hook_secret_test.go +++ b/internal/controller/email_hook_secret_test.go @@ -9,6 +9,7 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -172,6 +173,56 @@ func TestReconcileAuthRollsDeploymentWhenEmailHookSecretChanges(t *testing.T) { } } +func TestReconcileSecretsReportsInvalidEmailHookSecretAndRecovers(t *testing.T) { + t.Parallel() + + scheme := newIdempotencyTestScheme(t) + project := &supabasev1alpha1.SupabaseProject{ + TypeMeta: metav1.TypeMeta{APIVersion: supabasev1alpha1.GroupVersion.String(), Kind: "SupabaseProject"}, + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "default", UID: "project-uid"}, + Spec: supabasev1alpha1.SupabaseProjectSpec{Auth: supabasev1alpha1.AuthSpec{ + EmailHook: &supabasev1alpha1.EmailHookSpec{Enabled: true, URI: "https://email.example.com/auth"}, + }}, + Status: supabasev1alpha1.SupabaseProjectStatus{SecretNames: supabasev1alpha1.SecretNamesStatus{ + EmailHook: "app-email-hook", + }}, + } + objects := []client.Object{ + project, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "app-email-hook", Namespace: "default"}, Data: map[string][]byte{"secret": []byte("v1,whsec_invalid")}}, + } + for _, name := range []string{"app-jwt", "app-supabase-admin-password", "app-authenticator-password", "app-auth-admin-password"} { + objects = append(objects, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}}) + } + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(project).WithObjects(objects...).Build() + reconciler := &SupabaseProjectReconciler{Client: kubeClient, Scheme: scheme} + ctx := context.Background() + + if err := reconciler.reconcileSecrets(ctx, project); err == nil { + t.Fatal("reconcileSecrets() accepted an invalid email-hook secret") + } + condition := meta.FindStatusCondition(project.Status.Conditions, supabasev1alpha1.ConditionTypeSecretsReady) + if condition == nil || condition.Status != metav1.ConditionFalse { + t.Fatalf("SecretsReady condition after invalid secret = %#v, want False", condition) + } + + hookSecret := &corev1.Secret{} + if err := kubeClient.Get(ctx, client.ObjectKey{Name: "app-email-hook", Namespace: "default"}, hookSecret); err != nil { + t.Fatal(err) + } + hookSecret.Data["secret"] = emailHookSecretValue(3) + if err := kubeClient.Update(ctx, hookSecret); err != nil { + t.Fatal(err) + } + if err := reconciler.reconcileSecrets(ctx, project); err != nil { + t.Fatal(err) + } + condition = meta.FindStatusCondition(project.Status.Conditions, supabasev1alpha1.ConditionTypeSecretsReady) + if condition == nil || condition.Status != metav1.ConditionTrue { + t.Fatalf("SecretsReady condition after recovery = %#v, want True", condition) + } +} + func emailHookSecretValue(fill byte) []byte { return []byte("v1,whsec_" + base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{fill}, 32))) } diff --git a/internal/controller/supabaseproject_controller.go b/internal/controller/supabaseproject_controller.go index ad11b5a..f8390be 100644 --- a/internal/controller/supabaseproject_controller.go +++ b/internal/controller/supabaseproject_controller.go @@ -318,23 +318,36 @@ func (r *SupabaseProjectReconciler) reconcileEmailHookSecret(ctx context.Context err := r.Get(ctx, types.NamespacedName{Name: name, Namespace: project.Namespace}, existing) if err != nil { if !apierrors.IsNotFound(err) { - return err + return r.failEmailHookSecret(ctx, project, "EmailHookSecretUnavailable", err) } generated, _, err := secrets.GenerateEmailHookSecret(project) if err != nil { - return err + return r.failEmailHookSecret(ctx, project, "EmailHookSecretGenerationFailed", err) } if err := r.createOrUpdateSecret(ctx, project, generated); err != nil { - return err + return r.failEmailHookSecret(ctx, project, "EmailHookSecretCreateFailed", err) } } else if err := secrets.ValidateEmailHookSecret(existing); err != nil { - return err + return r.failEmailHookSecret(ctx, project, "InvalidEmailHookSecret", err) } project.Status.SecretNames.EmailHook = name + r.setCondition(project, supabasev1alpha1.ConditionTypeSecretsReady, metav1.ConditionTrue, "SecretsReady", "All secrets, including the email hook secret, are ready") return r.updateProjectStatus(ctx, project) } +func (r *SupabaseProjectReconciler) failEmailHookSecret(ctx context.Context, project *supabasev1alpha1.SupabaseProject, reason string, reconcileErr error) error { + r.setCondition(project, supabasev1alpha1.ConditionTypeSecretsReady, metav1.ConditionFalse, reason, reconcileErr.Error()) + if err := r.updateProjectStatus(ctx, project); err != nil { + return err + } + return reconcileErr +} + +func emailHookEnabled(project *supabasev1alpha1.SupabaseProject) bool { + return project.Spec.Auth.EmailHook != nil && project.Spec.Auth.EmailHook.Enabled +} + // reconcileUserSpecifiedSecrets validates and uses user-provided secrets func (r *SupabaseProjectReconciler) reconcileUserSpecifiedSecrets(ctx context.Context, project *supabasev1alpha1.SupabaseProject) error { log := logf.FromContext(ctx) @@ -399,7 +412,9 @@ func (r *SupabaseProjectReconciler) reconcileUserSpecifiedSecrets(ctx context.Co // All secrets validated successfully project.Status.SecretNames = secretNames - r.setCondition(project, supabasev1alpha1.ConditionTypeSecretsReady, metav1.ConditionTrue, "SecretsValidated", "All user-specified secrets are valid") + if !emailHookEnabled(project) { + r.setCondition(project, supabasev1alpha1.ConditionTypeSecretsReady, metav1.ConditionTrue, "SecretsValidated", "All user-specified secrets are valid") + } if err := r.updateProjectStatus(ctx, project); err != nil { return err } @@ -456,7 +471,9 @@ func (r *SupabaseProjectReconciler) reconcileAutoGeneratedSecrets(ctx context.Co } project.Status.SecretNames = secretNames - r.setCondition(project, supabasev1alpha1.ConditionTypeSecretsReady, metav1.ConditionTrue, "SecretsExist", "All secrets exist") + if !emailHookEnabled(project) { + r.setCondition(project, supabasev1alpha1.ConditionTypeSecretsReady, metav1.ConditionTrue, "SecretsExist", "All secrets exist") + } if err := r.updateProjectStatus(ctx, project); err != nil { return err } @@ -499,7 +516,9 @@ func (r *SupabaseProjectReconciler) reconcileAutoGeneratedSecrets(ctx context.Co // Update status with secret names project.Status.SecretNames = secretNames - r.setCondition(project, supabasev1alpha1.ConditionTypeSecretsReady, metav1.ConditionTrue, "SecretsCreated", "All secrets have been created") + if !emailHookEnabled(project) { + r.setCondition(project, supabasev1alpha1.ConditionTypeSecretsReady, metav1.ConditionTrue, "SecretsCreated", "All secrets have been created") + } if err := r.updateProjectStatus(ctx, project); err != nil { return err }