From 5abf36ec4c714987df204863737b4c065921515d Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Tue, 20 Jan 2026 18:43:20 +0000 Subject: [PATCH 1/2] Add GCP Service Account authentication for destinations Implements complete support for GCP_SERVICE_ACCOUNT authentication type, completing 100% coverage of all destination authentication methods in the Hookdeck API. Changes: - Add --destination-gcp-service-account-key and --destination-gcp-scope flags - Implement GCP auth builder with validation in buildAuthConfig() - Add 3 unit tests for GCP authentication (valid with/without scope, error case) - Add acceptance test for end-to-end GCP authentication flow - Update REFERENCE.md with GCP examples and Hookdeck Signature clarification - Update help text and error messages to include 'gcp' auth method Note: Hookdeck Signature authentication has no configurable properties per the OpenAPI spec - Hookdeck automatically handles signing without user configuration. Closes completion of all 9 destination authentication types: HOOKDECK_SIGNATURE, CUSTOM_SIGNATURE, BASIC_AUTH, API_KEY, BEARER_TOKEN, OAUTH2_CLIENT_CREDENTIALS, OAUTH2_AUTHORIZATION_CODE, AWS_SIGNATURE, and GCP_SERVICE_ACCOUNT. --- REFERENCE.md | 48 +++++++++++++--- pkg/cmd/connection_auth_test.go | 49 +++++++++++++++++ pkg/cmd/connection_create.go | 24 +++++++- pkg/cmd/connection_upsert.go | 6 +- test/acceptance/connection_oauth_aws_test.go | 58 ++++++++++++++++++++ 5 files changed, 175 insertions(+), 10 deletions(-) diff --git a/REFERENCE.md b/REFERENCE.md index 5dad4ae..33aae2e 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -976,7 +976,21 @@ hookdeck connection create \ --destination-url "https://api.example.com/stripe" ``` -**4. Destination with Bearer Token** +**4. Destination with Hookdeck Signature (Default)** +```bash +# Hookdeck automatically signs outgoing webhooks - no configuration needed +hookdeck connection create \ + --source-name "stripe-webhooks" \ + --source-type STRIPE \ + --source-webhook-secret "whsec_stripe_secret" \ + --destination-name "api-with-verification" \ + --destination-type HTTP \ + --destination-url "https://api.example.com/webhook" \ + --destination-auth-method hookdeck +``` +*Note: Hookdeck Signature authentication is the default. Hookdeck automatically signs all outgoing webhooks with a signature that can be verified using Hookdeck's verification libraries. No webhook secret needs to be configured.* + +**5. Destination with Bearer Token** ```bash hookdeck connection create \ --source-name "github-webhooks" \ @@ -985,9 +999,11 @@ hookdeck connection create \ --destination-name "ci-system" \ --destination-type HTTP \ --destination-url "https://ci.example.com/webhook" \ + --destination-auth-method bearer \ --destination-bearer-token "bearer_token_xyz" +``` -**5. Source with Custom Response and Allowed HTTP Methods** +**6. Source with Custom Response and Allowed HTTP Methods** ```bash hookdeck connection create \ --source-name "api-webhooks" \ @@ -1002,7 +1018,7 @@ hookdeck connection create \ #### Rule Configuration Examples -**6. Retry Rules** +**7. Retry Rules** ```bash hookdeck connection create \ --source-name "payment-webhooks" \ @@ -1015,7 +1031,7 @@ hookdeck connection create \ --rule-retry-interval 60000 ``` -**7. Filter Rules** +**8. Filter Rules** ```bash hookdeck connection create \ --source-name "events" \ @@ -1026,7 +1042,7 @@ hookdeck connection create \ --rule-filter-body '{"event_type":"payment.succeeded"}' ``` -**8. All Rule Types Combined** +**9. All Rule Types Combined** ```bash hookdeck connection create \ --source-name "shopify-webhooks" \ @@ -1042,7 +1058,7 @@ hookdeck connection create \ --rule-delay 5000 ``` -**9. Rate Limiting** +**10. Rate Limiting** ```bash hookdeck connection create \ --source-name "high-volume-source" \ @@ -1054,6 +1070,19 @@ hookdeck connection create \ --destination-rate-limit-period minute ``` +**11. GCP Service Account Authentication** +```bash +hookdeck connection create \ + --source-name "webhooks" \ + --source-type HTTP \ + --destination-name "gcp-cloud-function" \ + --destination-type HTTP \ + --destination-url "https://us-central1-project-id.cloudfunctions.net/function" \ + --destination-auth-method gcp \ + --destination-gcp-service-account-key '{"type":"service_account","project_id":"project-id","private_key_id":"key-id","private_key":"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n","client_email":"service-account@project-id.iam.gserviceaccount.com"}' \ + --destination-gcp-scope "https://www.googleapis.com/auth/cloud-platform" +``` + #### Available Flags **Connection Configuration:** @@ -1084,7 +1113,7 @@ hookdeck connection create \ - `--destination-cli-path ` - CLI path (default: `/`) - `--destination-path-forwarding-disabled ` - Disable path forwarding for HTTP destinations (default: false) - `--destination-http-method ` - HTTP method for HTTP destinations: `GET`, `POST`, `PUT`, `PATCH`, `DELETE` -- `--destination-auth-method ` - Authentication method: `hookdeck`, `bearer`, `basic`, `api_key`, `custom_signature`, `oauth2_client_credentials`, `oauth2_authorization_code`, `aws` +- `--destination-auth-method ` - Authentication method: `hookdeck`, `bearer`, `basic`, `api_key`, `custom_signature`, `oauth2_client_credentials`, `oauth2_authorization_code`, `aws`, `gcp` - `--destination-rate-limit ` - Rate limit (requests per period) - `--destination-rate-limit-period ` - Period: `second`, `minute`, `hour`, `day`, `month`, `year` @@ -1136,6 +1165,11 @@ hookdeck connection create \ - `--destination-aws-region ` - AWS region - `--destination-aws-service ` - AWS service name +*GCP Service Account:* +- `--destination-auth-method gcp` +- `--destination-gcp-service-account-key ` - GCP service account key JSON +- `--destination-gcp-scope ` - GCP scope (optional) + **Rules - Retry:** - `--rule-retry-strategy ` - Strategy: `linear`, `exponential` - `--rule-retry-count ` - Number of retry attempts (1-20) diff --git a/pkg/cmd/connection_auth_test.go b/pkg/cmd/connection_auth_test.go index eb68884..7b49dfc 100644 --- a/pkg/cmd/connection_auth_test.go +++ b/pkg/cmd/connection_auth_test.go @@ -295,6 +295,55 @@ func TestBuildAuthConfig(t *testing.T) { wantErr: true, errContains: "--destination-aws-region is required", }, + { + name: "gcp service account auth - valid", + setup: func(cc *connectionCreateCmd) { + cc.DestinationAuthMethod = "gcp" + cc.DestinationGCPServiceAccountKey = `{"type":"service_account","project_id":"test"}` + cc.DestinationGCPScope = "https://www.googleapis.com/auth/cloud-platform" + }, + wantType: "GCP_SERVICE_ACCOUNT", + wantErr: false, + validate: func(t *testing.T, config map[string]interface{}) { + if config["type"] != "GCP_SERVICE_ACCOUNT" { + t.Errorf("expected type GCP_SERVICE_ACCOUNT, got %v", config["type"]) + } + if config["service_account_key"] == "" { + t.Error("expected service_account_key to be set") + } + if config["scope"] != "https://www.googleapis.com/auth/cloud-platform" { + t.Errorf("expected scope, got %v", config["scope"]) + } + }, + }, + { + name: "gcp service account auth - valid without scope", + setup: func(cc *connectionCreateCmd) { + cc.DestinationAuthMethod = "gcp" + cc.DestinationGCPServiceAccountKey = `{"type":"service_account","project_id":"test"}` + }, + wantType: "GCP_SERVICE_ACCOUNT", + wantErr: false, + validate: func(t *testing.T, config map[string]interface{}) { + if config["type"] != "GCP_SERVICE_ACCOUNT" { + t.Errorf("expected type GCP_SERVICE_ACCOUNT, got %v", config["type"]) + } + if config["service_account_key"] == "" { + t.Error("expected service_account_key to be set") + } + if _, hasScope := config["scope"]; hasScope { + t.Error("expected scope to not be set when not provided") + } + }, + }, + { + name: "gcp service account auth - missing key", + setup: func(cc *connectionCreateCmd) { + cc.DestinationAuthMethod = "gcp" + }, + wantErr: true, + errContains: "--destination-gcp-service-account-key is required", + }, { name: "unsupported auth method", setup: func(cc *connectionCreateCmd) { diff --git a/pkg/cmd/connection_create.go b/pkg/cmd/connection_create.go index a0c5ae7..531047a 100644 --- a/pkg/cmd/connection_create.go +++ b/pkg/cmd/connection_create.go @@ -84,6 +84,10 @@ type connectionCreateCmd struct { DestinationAWSRegion string DestinationAWSService string + // GCP Service Account flags + DestinationGCPServiceAccountKey string + DestinationGCPScope string + // Destination rate limiting flags DestinationRateLimit int DestinationRateLimitPeriod string @@ -207,7 +211,7 @@ func newConnectionCreateCmd() *connectionCreateCmd { cc.cmd.Flags().StringVar(&cc.destinationHTTPMethod, "destination-http-method", "", "HTTP method for HTTP destinations (GET, POST, PUT, PATCH, DELETE)") // Destination authentication flags - cc.cmd.Flags().StringVar(&cc.DestinationAuthMethod, "destination-auth-method", "", "Authentication method for HTTP destinations (hookdeck, bearer, basic, api_key, custom_signature, oauth2_client_credentials, oauth2_authorization_code, aws)") + cc.cmd.Flags().StringVar(&cc.DestinationAuthMethod, "destination-auth-method", "", "Authentication method for HTTP destinations (hookdeck, bearer, basic, api_key, custom_signature, oauth2_client_credentials, oauth2_authorization_code, aws, gcp)") // Bearer Token cc.cmd.Flags().StringVar(&cc.DestinationBearerToken, "destination-bearer-token", "", "Bearer token for destination authentication") @@ -241,6 +245,10 @@ func newConnectionCreateCmd() *connectionCreateCmd { cc.cmd.Flags().StringVar(&cc.DestinationAWSRegion, "destination-aws-region", "", "AWS region") cc.cmd.Flags().StringVar(&cc.DestinationAWSService, "destination-aws-service", "", "AWS service name") + // GCP Service Account + cc.cmd.Flags().StringVar(&cc.DestinationGCPServiceAccountKey, "destination-gcp-service-account-key", "", "GCP service account key JSON for destination authentication") + cc.cmd.Flags().StringVar(&cc.DestinationGCPScope, "destination-gcp-scope", "", "GCP scope for service account authentication") + // Destination rate limiting flags cc.cmd.Flags().IntVar(&cc.DestinationRateLimit, "destination-rate-limit", 0, "Rate limit for destination (requests per period)") cc.cmd.Flags().StringVar(&cc.DestinationRateLimitPeriod, "destination-rate-limit-period", "", "Rate limit period (second, minute, hour, concurrent)") @@ -790,8 +798,20 @@ func (cc *connectionCreateCmd) buildAuthConfig() (map[string]interface{}, error) authConfig["region"] = cc.DestinationAWSRegion authConfig["service"] = cc.DestinationAWSService + case "gcp": + // GCP_SERVICE_ACCOUNT + if cc.DestinationGCPServiceAccountKey == "" { + return nil, fmt.Errorf("--destination-gcp-service-account-key is required for gcp auth method") + } + authConfig["type"] = "GCP_SERVICE_ACCOUNT" + authConfig["service_account_key"] = cc.DestinationGCPServiceAccountKey + + if cc.DestinationGCPScope != "" { + authConfig["scope"] = cc.DestinationGCPScope + } + default: - return nil, fmt.Errorf("unsupported destination authentication method: %s (supported: hookdeck, bearer, basic, api_key, custom_signature, oauth2_client_credentials, oauth2_authorization_code, aws)", cc.DestinationAuthMethod) + return nil, fmt.Errorf("unsupported destination authentication method: %s (supported: hookdeck, bearer, basic, api_key, custom_signature, oauth2_client_credentials, oauth2_authorization_code, aws, gcp)", cc.DestinationAuthMethod) } return authConfig, nil diff --git a/pkg/cmd/connection_upsert.go b/pkg/cmd/connection_upsert.go index 221369d..0612c0c 100644 --- a/pkg/cmd/connection_upsert.go +++ b/pkg/cmd/connection_upsert.go @@ -116,7 +116,7 @@ func newConnectionUpsertCmd() *connectionUpsertCmd { cu.cmd.Flags().StringVar(&cu.destinationHTTPMethod, "destination-http-method", "", "HTTP method for HTTP destinations (GET, POST, PUT, PATCH, DELETE)") // Destination authentication flags - cu.cmd.Flags().StringVar(&cu.DestinationAuthMethod, "destination-auth-method", "", "Authentication method for HTTP destinations (hookdeck, bearer, basic, api_key, custom_signature, oauth2_client_credentials, oauth2_authorization_code, aws)") + cu.cmd.Flags().StringVar(&cu.DestinationAuthMethod, "destination-auth-method", "", "Authentication method for HTTP destinations (hookdeck, bearer, basic, api_key, custom_signature, oauth2_client_credentials, oauth2_authorization_code, aws, gcp)") // Bearer Token cu.cmd.Flags().StringVar(&cu.DestinationBearerToken, "destination-bearer-token", "", "Bearer token for destination authentication") @@ -150,6 +150,10 @@ func newConnectionUpsertCmd() *connectionUpsertCmd { cu.cmd.Flags().StringVar(&cu.DestinationAWSRegion, "destination-aws-region", "", "AWS region") cu.cmd.Flags().StringVar(&cu.DestinationAWSService, "destination-aws-service", "", "AWS service name") + // GCP Service Account + cu.cmd.Flags().StringVar(&cu.DestinationGCPServiceAccountKey, "destination-gcp-service-account-key", "", "GCP service account key JSON for destination authentication") + cu.cmd.Flags().StringVar(&cu.DestinationGCPScope, "destination-gcp-scope", "", "GCP scope for service account authentication") + // Destination rate limiting flags cu.cmd.Flags().IntVar(&cu.DestinationRateLimit, "destination-rate-limit", 0, "Rate limit for destination (requests per period)") cu.cmd.Flags().StringVar(&cu.DestinationRateLimitPeriod, "destination-rate-limit-period", "", "Rate limit period (second, minute, hour, concurrent)") diff --git a/test/acceptance/connection_oauth_aws_test.go b/test/acceptance/connection_oauth_aws_test.go index cd9cb58..b851bdf 100644 --- a/test/acceptance/connection_oauth_aws_test.go +++ b/test/acceptance/connection_oauth_aws_test.go @@ -184,4 +184,62 @@ func TestConnectionOAuth2AWSAuthentication(t *testing.T) { t.Logf("Successfully tested HTTP destination with AWS Signature: %s", connID) }) + + t.Run("HTTP_Destination_GCP_ServiceAccount", func(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + + connName := "test-gcp-sa-conn-" + timestamp + sourceName := "test-gcp-sa-source-" + timestamp + destName := "test-gcp-sa-dest-" + timestamp + destURL := "https://api.hookdeck.com/dev/null" + + // Create connection with HTTP destination (GCP Service Account) + // Using a minimal but valid JSON structure for service account key + serviceAccountKey := `{"type":"service_account","project_id":"test-project","private_key_id":"test-key-id","private_key":"-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC\n-----END PRIVATE KEY-----\n","client_email":"test@test-project.iam.gserviceaccount.com","client_id":"123456789","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token"}` + + stdout, stderr, err := cli.Run("connection", "create", + "--name", connName, + "--source-type", "WEBHOOK", + "--source-name", sourceName, + "--destination-type", "HTTP", + "--destination-name", destName, + "--destination-url", destURL, + "--destination-auth-method", "gcp", + "--destination-gcp-service-account-key", serviceAccountKey, + "--destination-gcp-scope", "https://www.googleapis.com/auth/cloud-platform", + "--output", "json") + require.NoError(t, err, "Failed to create connection: stderr=%s", stderr) + + var createResp map[string]interface{} + err = json.Unmarshal([]byte(stdout), &createResp) + require.NoError(t, err, "Failed to parse creation response: %s", stdout) + + connID, ok := createResp["id"].(string) + require.True(t, ok && connID != "", "Expected connection ID in creation response") + + // Verify destination auth configuration + dest, ok := createResp["destination"].(map[string]interface{}) + require.True(t, ok, "Expected destination object in creation response") + + destConfig, ok := dest["config"].(map[string]interface{}) + require.True(t, ok, "Expected destination config object") + + if authMethod, ok := destConfig["auth_method"].(map[string]interface{}); ok { + assert.Equal(t, "GCP_SERVICE_ACCOUNT", authMethod["type"], "Auth type should be GCP_SERVICE_ACCOUNT") + assert.Equal(t, "https://www.googleapis.com/auth/cloud-platform", authMethod["scope"], "GCP scope should match") + // Service account key should not be returned for security reasons + } + + // Cleanup + t.Cleanup(func() { + deleteConnection(t, cli, connID) + }) + + t.Logf("Successfully tested HTTP destination with GCP Service Account: %s", connID) + }) } From 85a5e2f45039ad6fdf974194719d7a6cca4e3dfb Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Tue, 20 Jan 2026 18:48:20 +0000 Subject: [PATCH 2/2] Fix deduplicate rule test to use API-compliant window value The API requires deduplicate window values to be multiples of 1000. Changed from 86400 to 60000 milliseconds (60 seconds) to satisfy the validation requirement. Fixes: TestConnectionWithDeduplicateRule Error: "window must be a multiple of 1000" --- test/acceptance/connection_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/acceptance/connection_test.go b/test/acceptance/connection_test.go index ca4f670..9920057 100644 --- a/test/acceptance/connection_test.go +++ b/test/acceptance/connection_test.go @@ -1323,7 +1323,7 @@ func TestConnectionWithDeduplicateRule(t *testing.T) { "--destination-name", destName, "--destination-type", "CLI", "--destination-cli-path", "/webhooks", - "--rule-deduplicate-window", "86400", + "--rule-deduplicate-window", "60000", "--rule-deduplicate-include-fields", "body.id,body.timestamp", ) require.NoError(t, err, "Should create connection with deduplicate rule") @@ -1344,7 +1344,7 @@ func TestConnectionWithDeduplicateRule(t *testing.T) { rule := getConn.Rules[0] assert.Equal(t, "deduplicate", rule["type"], "Rule type should be deduplicate") - assert.Equal(t, float64(86400), rule["window"], "Deduplicate window should be 86400 milliseconds") + assert.Equal(t, float64(60000), rule["window"], "Deduplicate window should be 60000 milliseconds (60 seconds)") // Verify include_fields is correctly set and matches our input if includeFields, ok := rule["include_fields"].([]interface{}); ok {