chore(webhook): reveal-later signing secret flow
Generate webhook signing secrets server-side and let users reveal them on demand, replacing the create-dialog secret controls that surfaced internal mask state (Status / Generate & Copy / Clear / Pending) to users. - Add owner-gated GetUserWebhookSigningSecret RPC — the only path that returns the secret; list/create/update responses still omit it. - Generate the secret server-side on create (webhook.GenerateSigningSecret), so validity no longer depends on the client. - Rename UserWebhook.has_signing_secret -> signing_secret_set for parity with the existing api_key_set field. - Create dialog drops the secret section to a one-line note; the generated secret is shown once right after create and revealable from Edit later.
This commit is contained in:
@@ -269,6 +269,14 @@ func (s *ConnectServiceHandler) DeleteUserWebhook(ctx context.Context, req *conn
|
||||
return connect.NewResponse(resp), nil
|
||||
}
|
||||
|
||||
func (s *ConnectServiceHandler) GetUserWebhookSigningSecret(ctx context.Context, req *connect.Request[v1pb.GetUserWebhookSigningSecretRequest]) (*connect.Response[v1pb.GetUserWebhookSigningSecretResponse], error) {
|
||||
resp, err := s.APIV1Service.GetUserWebhookSigningSecret(ctx, req.Msg)
|
||||
if err != nil {
|
||||
return nil, convertGRPCError(err)
|
||||
}
|
||||
return connect.NewResponse(resp), nil
|
||||
}
|
||||
|
||||
func (s *ConnectServiceHandler) ListUserNotifications(ctx context.Context, req *connect.Request[v1pb.ListUserNotificationsRequest]) (*connect.Response[v1pb.ListUserNotificationsResponse], error) {
|
||||
resp, err := s.APIV1Service.ListUserNotifications(ctx, req.Msg)
|
||||
if err != nil {
|
||||
|
||||
@@ -1095,7 +1095,16 @@ func (s *APIV1Service) CreateUserWebhook(ctx context.Context, request *v1pb.Crea
|
||||
if err := webhook.ValidateURL(strings.TrimSpace(request.Webhook.Url)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := webhook.ValidateSigningSecret(strings.TrimSpace(request.Webhook.SigningSecret)); err != nil {
|
||||
// The signing secret is generated server-side so it always meets the Standard
|
||||
// Webhooks length requirement. A client-supplied secret is still accepted (and
|
||||
// validated) for backward compatibility.
|
||||
signingSecret := strings.TrimSpace(request.Webhook.SigningSecret)
|
||||
if signingSecret == "" {
|
||||
signingSecret, err = webhook.GenerateSigningSecret()
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to generate signing secret: %v", err)
|
||||
}
|
||||
} else if err := webhook.ValidateSigningSecret(signingSecret); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1104,7 +1113,7 @@ func (s *APIV1Service) CreateUserWebhook(ctx context.Context, request *v1pb.Crea
|
||||
Id: webhookID,
|
||||
Title: request.Webhook.DisplayName,
|
||||
Url: strings.TrimSpace(request.Webhook.Url),
|
||||
SigningSecret: strings.TrimSpace(request.Webhook.SigningSecret),
|
||||
SigningSecret: signingSecret,
|
||||
}
|
||||
|
||||
err = s.Store.AddUserWebhook(ctx, userID, webhook)
|
||||
@@ -1259,6 +1268,34 @@ func (s *APIV1Service) DeleteUserWebhook(ctx context.Context, request *v1pb.Dele
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
// GetUserWebhookSigningSecret reveals the signing secret for a single webhook.
|
||||
// This is the only endpoint that returns the secret value; it is gated to the
|
||||
// webhook owner (or an admin) and the secret is never included in list/create/update responses.
|
||||
func (s *APIV1Service) GetUserWebhookSigningSecret(ctx context.Context, request *v1pb.GetUserWebhookSigningSecretRequest) (*v1pb.GetUserWebhookSigningSecretResponse, error) {
|
||||
user, webhookID, err := s.resolveUserAndWebhookIDFromName(ctx, request.Name)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid webhook name: %v", err)
|
||||
}
|
||||
userID := user.ID
|
||||
|
||||
if _, err := s.authorizeUserResourceAccess(ctx, userID, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
webhooks, err := s.Store.GetUserWebhooks(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user webhooks: %v", err)
|
||||
}
|
||||
|
||||
for _, webhook := range webhooks {
|
||||
if webhook.Id == webhookID {
|
||||
return &v1pb.GetUserWebhookSigningSecretResponse{SigningSecret: webhook.SigningSecret}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, status.Errorf(codes.NotFound, "webhook not found")
|
||||
}
|
||||
|
||||
// Helper functions for webhook operations
|
||||
|
||||
// generateUserWebhookID generates a unique ID for user webhooks.
|
||||
@@ -1274,7 +1311,7 @@ func convertUserWebhookFromUserSetting(webhook *storepb.WebhooksUserSetting_Webh
|
||||
Name: fmt.Sprintf("%s/webhooks/%s", BuildUserName(user.Username), webhook.Id),
|
||||
Url: webhook.Url,
|
||||
DisplayName: webhook.Title,
|
||||
HasSigningSecret: webhook.SigningSecret != "",
|
||||
SigningSecretSet: webhook.SigningSecret != "",
|
||||
// Note: create_time and update_time are not available in the user setting webhook structure
|
||||
// This is a limitation of storing webhooks in user settings vs the dedicated webhook table
|
||||
}
|
||||
@@ -1474,7 +1511,7 @@ func convertUserSettingFromStore(storeSetting *storepb.UserSetting, user *store.
|
||||
Name: fmt.Sprintf("%s/webhooks/%s", BuildUserName(user.Username), webhook.Id),
|
||||
Url: webhook.Url,
|
||||
DisplayName: webhook.Title,
|
||||
HasSigningSecret: webhook.SigningSecret != "",
|
||||
SigningSecretSet: webhook.SigningSecret != "",
|
||||
}
|
||||
apiWebhooks = append(apiWebhooks, apiWebhook)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
@@ -26,5 +31,43 @@ func TestConvertUserWebhookFromUserSettingOmitsSigningSecret(t *testing.T) {
|
||||
require.Equal(t, "My Webhook", apiWebhook.DisplayName)
|
||||
require.Equal(t, "https://example.com/postreceive", apiWebhook.Url)
|
||||
require.Empty(t, apiWebhook.SigningSecret, "signing secret must never be returned in API responses")
|
||||
require.True(t, apiWebhook.HasSigningSecret, "has_signing_secret must be true when secret is configured")
|
||||
require.True(t, apiWebhook.SigningSecretSet, "signing_secret_set must be true when secret is configured")
|
||||
}
|
||||
|
||||
// TestUserWebhookSigningSecretLifecycle covers the reveal-later flow: a webhook
|
||||
// created without a secret is auto-assigned one server-side (exposed only via the
|
||||
// signing_secret_set flag), the owner can reveal the value, and a non-owner cannot.
|
||||
func TestUserWebhookSigningSecretLifecycle(t *testing.T) {
|
||||
svc := newIntegrationService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// First user initializes the store as host/admin; the test actors are regular users.
|
||||
_, err := svc.Store.CreateUser(ctx, &store.User{Username: "host", Role: store.RoleAdmin, Email: "host@example.com"})
|
||||
require.NoError(t, err)
|
||||
owner, err := svc.Store.CreateUser(ctx, &store.User{Username: "owner", Role: store.RoleUser, Email: "owner@example.com"})
|
||||
require.NoError(t, err)
|
||||
other, err := svc.Store.CreateUser(ctx, &store.User{Username: "other", Role: store.RoleUser, Email: "other@example.com"})
|
||||
require.NoError(t, err)
|
||||
|
||||
ownerCtx := userCtx(ctx, owner.ID)
|
||||
otherCtx := userCtx(ctx, other.ID)
|
||||
|
||||
// Create without supplying a secret -> the server generates one.
|
||||
created, err := svc.CreateUserWebhook(ownerCtx, &v1pb.CreateUserWebhookRequest{
|
||||
Parent: "users/owner",
|
||||
Webhook: &v1pb.UserWebhook{DisplayName: "deploy", Url: "https://example.com/postreceive"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, created.SigningSecretSet, "create must auto-generate a signing secret")
|
||||
require.Empty(t, created.SigningSecret, "create response must never carry the secret value")
|
||||
|
||||
// The owner can reveal the generated value.
|
||||
revealed, err := svc.GetUserWebhookSigningSecret(ownerCtx, &v1pb.GetUserWebhookSigningSecretRequest{Name: created.Name})
|
||||
require.NoError(t, err)
|
||||
require.True(t, strings.HasPrefix(revealed.SigningSecret, "whsec_"), "revealed secret must be in whsec_ form")
|
||||
|
||||
// A non-owner, non-admin user cannot reveal it.
|
||||
_, err = svc.GetUserWebhookSigningSecret(otherCtx, &v1pb.GetUserWebhookSigningSecretRequest{Name: created.Name})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, codes.PermissionDenied, status.Code(err), "non-owner must be denied")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user