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/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 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..ceb5652 --- /dev/null +++ b/internal/controller/email_hook_secret_test.go @@ -0,0 +1,228 @@ +package controller + +import ( + "bytes" + "context" + "encoding/base64" + "strings" + "testing" + + 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" + + 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") + } +} + +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 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 41df8cb..f8390be 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. @@ -288,11 +290,62 @@ 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) + } + + name := secrets.EmailHookSecretName(project) + existing := &corev1.Secret{} + err := r.Get(ctx, types.NamespacedName{Name: name, Namespace: project.Namespace}, existing) + if err != nil { + if !apierrors.IsNotFound(err) { + return r.failEmailHookSecret(ctx, project, "EmailHookSecretUnavailable", err) + } + generated, _, err := secrets.GenerateEmailHookSecret(project) + if err != nil { + return r.failEmailHookSecret(ctx, project, "EmailHookSecretGenerationFailed", err) + } + if err := r.createOrUpdateSecret(ctx, project, generated); err != nil { + return r.failEmailHookSecret(ctx, project, "EmailHookSecretCreateFailed", err) + } + } else if err := secrets.ValidateEmailHookSecret(existing); err != nil { + 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 @@ -301,6 +354,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{} @@ -358,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 } @@ -389,6 +445,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 @@ -414,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 } @@ -434,6 +493,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 { @@ -456,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 } @@ -696,10 +758,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} }, ) @@ -712,6 +778,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/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..3f1b5df 100644 --- a/internal/resources/secrets/secrets.go +++ b/internal/resources/secrets/secrets.go @@ -17,7 +17,9 @@ limitations under the License. package secrets import ( + "encoding/base64" "fmt" + "strings" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -33,6 +35,11 @@ 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" + +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 @@ -143,6 +150,47 @@ 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) + } + 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 +} + // 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..f9e6144 100644 --- a/internal/resources/secrets/secrets_test.go +++ b/internal/resources/secrets/secrets_test.go @@ -1,13 +1,66 @@ 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" ) +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 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},