diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index ecf64e6973e..4c02d1d6d54 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -206,7 +206,7 @@ jobs: matrix: type: - cmd: go_core_tests - os: runs-on=${{ github.run_id }}-unit/cpu=48/ram=96/family=c6i/spot=false/image=ubuntu24-full-x64/extras=s3-cache+tmpfs + os: runs-on=${{ github.run_id }}-unit/cpu=48/ram=96/family=c6id+c5ad/spot=false/image=ubuntu24-full-x64/extras=s3-cache should-run: ${{ needs.filter.outputs.should-run-core-tests }} trunk-auto-quarantine: "true" diff --git a/.github/workflows/cre-system-tests.yaml b/.github/workflows/cre-system-tests.yaml index 60ec33e105e..4e22bf6185f 100644 --- a/.github/workflows/cre-system-tests.yaml +++ b/.github/workflows/cre-system-tests.yaml @@ -86,6 +86,10 @@ jobs: # Add list of tests with certain topologies PER_TEST_TOPOLOGIES_JSON=${PER_TEST_TOPOLOGIES_JSON:-'{ + "Test_CRE_V2_Suite_Bucket_B": [ + {"topology":"workflow-gateway-capabilities","configs":"configs/workflow-gateway-capabilities-don.toml"}, + {"topology":"workflow-gateway-capabilities-vault-jwt_auth-enabled","configs":"configs/workflow-gateway-capabilities-don-vault-jwt_auth-enabled.toml"} + ], "Test_CRE_V2_Aptos_Suite": [ {"topology":"workflow-gateway-aptos","configs":"configs/workflow-gateway-don-aptos.toml"} ], diff --git a/core/capabilities/vault/authorizer_test.go b/core/capabilities/vault/authorizer_test.go index 18f717aaf19..f0ecbb7ad91 100644 --- a/core/capabilities/vault/authorizer_test.go +++ b/core/capabilities/vault/authorizer_test.go @@ -11,29 +11,20 @@ import ( vaultcommon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault" jsonrpc "github.com/smartcontractkit/chainlink-common/pkg/jsonrpc2" - "github.com/smartcontractkit/chainlink-common/pkg/settings/cresettings" - "github.com/smartcontractkit/chainlink-common/pkg/settings/limits" vault "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault" vaultmocks "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/mocks" "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes" "github.com/smartcontractkit/chainlink/v2/core/logger" ) -func testLimitsFactory() limits.Factory { - return limits.Factory{Settings: cresettings.DefaultGetter} -} - -func TestAuthorizer_RejectsJWTBasedAuthWhenDisabled(t *testing.T) { +func TestAuthorizer_RejectsJWTBasedAuthWhenUnavailable(t *testing.T) { params, err := json.Marshal(vaultcommon.CreateSecretsRequest{}) require.NoError(t, err) allowListBasedAuth := vaultmocks.NewAuthorizer(t) allowListBasedAuth.EXPECT().AuthorizeRequest(mock.Anything, mock.Anything).Maybe() - jwtBasedAuth, err := vault.NewJWTBasedAuth(vault.JWTBasedAuthConfig{}, testLimitsFactory(), logger.TestLogger(t), vault.WithDisabledJWTBasedAuth()) - require.NoError(t, err) - - a := vault.NewAuthorizer(allowListBasedAuth, jwtBasedAuth, logger.TestLogger(t)) + a := vault.NewAuthorizer(allowListBasedAuth, nil, logger.TestLogger(t)) authResult, err := a.AuthorizeRequest(t.Context(), jsonrpc.Request[json.RawMessage]{ ID: "1", @@ -42,7 +33,7 @@ func TestAuthorizer_RejectsJWTBasedAuthWhenDisabled(t *testing.T) { Auth: "jwt-token", }) require.Nil(t, authResult) - require.ErrorContains(t, err, "JWTBasedAuth is disabled") + require.ErrorContains(t, err, "JWTBasedAuth is nil") allowListBasedAuth.AssertNotCalled(t, "AuthorizeRequest", mock.Anything, mock.Anything) } @@ -126,10 +117,7 @@ func TestAuthorizer_RejectsAllowListBasedAuthReplay(t *testing.T) { req := jsonrpc.Request[json.RawMessage]{ID: "1", Method: vaulttypes.MethodSecretsCreate} allowListBasedAuth.EXPECT().AuthorizeRequest(mock.Anything, req).Return(vault.NewAuthResult("", "0xabc", "digest-1", time.Now().Add(time.Minute).Unix()), nil).Twice() - jwtBasedAuth, err := vault.NewJWTBasedAuth(vault.JWTBasedAuthConfig{}, testLimitsFactory(), logger.TestLogger(t), vault.WithDisabledJWTBasedAuth()) - require.NoError(t, err) - - a := vault.NewAuthorizer(allowListBasedAuth, jwtBasedAuth, logger.TestLogger(t)) + a := vault.NewAuthorizer(allowListBasedAuth, nil, logger.TestLogger(t)) authResult, err := a.AuthorizeRequest(t.Context(), req) require.NoError(t, err) diff --git a/core/capabilities/vault/gw_handler.go b/core/capabilities/vault/gw_handler.go index 71e8646ad6c..f15627b2e91 100644 --- a/core/capabilities/vault/gw_handler.go +++ b/core/capabilities/vault/gw_handler.go @@ -58,20 +58,6 @@ type gatewayConnector interface { RemoveHandler(ctx context.Context, methods []string) error } -type gatewayHandlerConfig struct { - authorizer Authorizer -} - -// GatewayHandlerOption customizes GatewayHandler construction for tests and future auth extensions. -type GatewayHandlerOption func(*gatewayHandlerConfig) - -// WithAuthorizer overrides the default Vault request authorizer. -func WithAuthorizer(authorizer Authorizer) GatewayHandlerOption { - return func(cfg *gatewayHandlerConfig) { - cfg.authorizer = authorizer - } -} - // GatewayHandler serves Vault requests received from the gateway on the node side. type GatewayHandler struct { services.Service @@ -86,24 +72,36 @@ type GatewayHandler struct { } // NewGatewayHandler creates a Vault gateway connector handler with internal auth wiring. -func NewGatewayHandler(secretsService vaulttypes.SecretsService, connector gatewayConnector, workflowRegistrySyncer workflowsyncerv2.WorkflowRegistrySyncer, lggr logger.Logger, limitsFactory limits.Factory, opts ...GatewayHandlerOption) (*GatewayHandler, error) { - cfg := gatewayHandlerConfig{} - for _, opt := range opts { - opt(&cfg) - } - if cfg.authorizer == nil { - allowListBasedAuth := NewAllowListBasedAuth(lggr, workflowRegistrySyncer) - jwtBasedAuth, err := NewJWTBasedAuth(JWTBasedAuthConfig{}, limitsFactory, lggr, WithDisabledJWTBasedAuth()) +// Pass a non-nil authorizer only in tests or other cases that need to override the default +// allowlist/JWT authorization chain. +func NewGatewayHandler( + secretsService vaulttypes.SecretsService, + connector gatewayConnector, + workflowRegistrySyncer workflowsyncerv2.WorkflowRegistrySyncer, + lggr logger.Logger, + limitsFactory limits.Factory, + authorizer Authorizer, + auth0 *Auth0Config, +) (*GatewayHandler, error) { + var jwtAuthService services.Service + var jwtBasedAuth Authorizer + if auth0 != nil { + var err error + jwtAuthService, err = NewJWTBasedAuth(JWTBasedAuthConfig{ + IssuerURL: auth0.IssuerURL, + Audience: auth0.Audience, + }, limitsFactory, lggr) if err != nil { return nil, fmt.Errorf("failed to create JWTBasedAuth: %w", err) } - cfg.authorizer = NewAuthorizer(allowListBasedAuth, jwtBasedAuth, lggr) - return newGatewayHandlerWithAuthorizer(secretsService, connector, cfg.authorizer, jwtBasedAuth, lggr) + jwtBasedAuth = jwtAuthService.(Authorizer) + } + + if authorizer == nil { + allowListBasedAuth := NewAllowListBasedAuth(lggr, workflowRegistrySyncer) + authorizer = NewAuthorizer(allowListBasedAuth, jwtBasedAuth, lggr) } - return newGatewayHandlerWithAuthorizer(secretsService, connector, cfg.authorizer, nil, lggr) -} -func newGatewayHandlerWithAuthorizer(secretsService vaulttypes.SecretsService, connector gatewayConnector, authorizer Authorizer, jwtAuthService services.Service, lggr logger.Logger) (*GatewayHandler, error) { metrics, err := newMetrics() if err != nil { return nil, fmt.Errorf("failed to create metrics: %w", err) @@ -227,6 +225,11 @@ func (h *GatewayHandler) authorizeAndPrefixRequest(ctx context.Context, req *jso h.lggr.Errorw("failed to normalize gateway request for authorization", "method", req.Method, "requestID", originalRequestID, "error", err) return nil, err } + authReq, err := StripRequestIdentity(authReq) + if err != nil { + h.lggr.Errorw("failed to strip authorized identity fields before authorization", "method", req.Method, "requestID", originalRequestID, "error", err) + return nil, err + } h.lggr.Debugw("authorizing gateway request", "method", req.Method, "requestID", originalRequestID) authResult, err := h.authorizer.AuthorizeRequest(ctx, authReq) diff --git a/core/capabilities/vault/gw_handler_test.go b/core/capabilities/vault/gw_handler_test.go index 4d853877dca..f71b3ea6daa 100644 --- a/core/capabilities/vault/gw_handler_test.go +++ b/core/capabilities/vault/gw_handler_test.go @@ -121,6 +121,56 @@ func TestGatewayHandler_HandleGatewayMessage(t *testing.T) { }, expectedError: false, }, + { + name: "success - create secrets strips forwarded identity before reauthorization", + setupMocks: func(ss *vaulttypesmocks.SecretsService, gc *connector_mocks.GatewayConnector, ra *vaultcapmocks.Authorizer) { + ra.EXPECT().AuthorizeRequest(mock.Anything, mock.MatchedBy(func(req jsonrpc.Request[json.RawMessage]) bool { + if req.Method != vaulttypes.MethodSecretsCreate || req.ID != "1" || req.Params == nil { + return false + } + parsed := &vaultcommon.CreateSecretsRequest{} + if err := json.Unmarshal(*req.Params, parsed); err != nil { + return false + } + return parsed.OrgId == "" && parsed.WorkflowOwner == "" + })).Return(authResult("org-1", "0xworkflow"), nil) + ss.EXPECT().CreateSecrets(mock.Anything, mock.MatchedBy(func(req *vaultcommon.CreateSecretsRequest) bool { + return len(req.EncryptedSecrets) == 1 && + req.EncryptedSecrets[0].Id.Key == "test-secret" && + req.EncryptedSecrets[0].Id.Owner == "org-1" && + req.RequestId == "org-1"+vaulttypes.RequestIDSeparator+"1" && + req.OrgId == "org-1" && + req.WorkflowOwner == "0xworkflow" + })).Return(&vaulttypes.Response{ID: "test-secret"}, nil) + + gc.On("SendToGateway", mock.Anything, "gateway-1", mock.MatchedBy(func(resp *jsonrpc.Response[json.RawMessage]) bool { + return resp.Error == nil + })).Return(nil) + }, + request: &jsonrpc.Request[json.RawMessage]{ + Method: vaulttypes.MethodSecretsCreate, + ID: "org-1" + vaulttypes.RequestIDSeparator + "1", + Params: func() *json.RawMessage { + params, _ := json.Marshal(vaultcommon.CreateSecretsRequest{ + RequestId: "org-1" + vaulttypes.RequestIDSeparator + "1", + OrgId: "org-1", + WorkflowOwner: "0xworkflow", + EncryptedSecrets: []*vaultcommon.EncryptedSecret{ + { + Id: &vaultcommon.SecretIdentifier{ + Key: "test-secret", + Owner: "org-1", + }, + EncryptedValue: "encrypted-value", + }, + }, + }) + raw := json.RawMessage(params) + return &raw + }(), + }, + expectedError: false, + }, { name: "failure - service error", setupMocks: func(ss *vaulttypesmocks.SecretsService, gc *connector_mocks.GatewayConnector, ra *vaultcapmocks.Authorizer) { @@ -456,9 +506,6 @@ func TestGatewayHandler_HandleGatewayMessage(t *testing.T) { secretsService := vaulttypesmocks.NewSecretsService(t) gwConnector := connector_mocks.NewGatewayConnector(t) allowListBasedAuth := vaultcapmocks.NewAuthorizer(t) - limitsFactory := limits.Factory{Settings: cresettings.DefaultGetter} - jwtBasedAuth, err := vaultcap.NewJWTBasedAuth(vaultcap.JWTBasedAuthConfig{}, limitsFactory, lggr, vaultcap.WithDisabledJWTBasedAuth()) - require.NoError(t, err) tt.setupMocks(secretsService, gwConnector, allowListBasedAuth) @@ -467,8 +514,9 @@ func TestGatewayHandler_HandleGatewayMessage(t *testing.T) { gwConnector, nil, lggr, - limitsFactory, - vaultcap.WithAuthorizer(vaultcap.NewAuthorizer(allowListBasedAuth, jwtBasedAuth, lggr)), + limits.Factory{Settings: cresettings.DefaultGetter}, + vaultcap.NewAuthorizer(allowListBasedAuth, nil, lggr), + nil, ) require.NoError(t, err) @@ -490,17 +538,15 @@ func TestGatewayHandler_Lifecycle(t *testing.T) { secretsService := vaulttypesmocks.NewSecretsService(t) gwConnector := connector_mocks.NewGatewayConnector(t) allowListBasedAuth := vaultcapmocks.NewAuthorizer(t) - limitsFactory := limits.Factory{Settings: cresettings.DefaultGetter} - jwtBasedAuth, err := vaultcap.NewJWTBasedAuth(vaultcap.JWTBasedAuthConfig{}, limitsFactory, lggr, vaultcap.WithDisabledJWTBasedAuth()) - require.NoError(t, err) handler, err := vaultcap.NewGatewayHandler( secretsService, gwConnector, nil, lggr, - limitsFactory, - vaultcap.WithAuthorizer(vaultcap.NewAuthorizer(allowListBasedAuth, jwtBasedAuth, lggr)), + limits.Factory{Settings: cresettings.DefaultGetter}, + vaultcap.NewAuthorizer(allowListBasedAuth, nil, lggr), + nil, ) require.NoError(t, err) @@ -522,3 +568,28 @@ func TestGatewayHandler_Lifecycle(t *testing.T) { assert.Equal(t, vaultcap.HandlerName, id) }) } + +func TestGatewayHandler_Lifecycle_DefaultAuthorizer_NoJWTConfig(t *testing.T) { + lggr := logger.TestLogger(t) + ctx := t.Context() + + secretsService := vaulttypesmocks.NewSecretsService(t) + gwConnector := connector_mocks.NewGatewayConnector(t) + + handler, err := vaultcap.NewGatewayHandler( + secretsService, + gwConnector, + nil, + lggr, + limits.Factory{Settings: cresettings.DefaultGetter}, + nil, + nil, + ) + require.NoError(t, err) + + gwConnector.On("AddHandler", mock.Anything, vaulttypes.Methods, handler).Return(nil).Once() + require.NoError(t, handler.Start(ctx)) + + gwConnector.On("RemoveHandler", mock.Anything, vaulttypes.Methods).Return(nil).Once() + require.NoError(t, handler.Close()) +} diff --git a/core/capabilities/vault/jwt_based_auth.go b/core/capabilities/vault/jwt_based_auth.go index 69edcc8e4cc..5a919f87f41 100644 --- a/core/capabilities/vault/jwt_based_auth.go +++ b/core/capabilities/vault/jwt_based_auth.go @@ -27,6 +27,7 @@ var ( ErrMissingToken = errors.New("missing JWT token") ErrInvalidToken = errors.New("invalid JWT token") ErrMissingOrgID = errors.New("missing org_id claim") + ErrMissingWorkflowOwner = errors.New("missing workflow_owner in authorization_details") ErrMissingRequestDigest = errors.New("missing request_digest in authorization_details") ErrJWKSFetchFailed = errors.New("failed to fetch JWKS") ErrJWKSKeyNotFound = errors.New("signing key not found in JWKS") @@ -37,6 +38,12 @@ const ( defaultHTTPTimeout = 5 * time.Second ) +// Auth0Config captures the Vault JWT issuer settings shared by gateway and node handlers. +type Auth0Config struct { + IssuerURL string `json:"issuerURL" toml:"issuerURL" yaml:"issuerURL"` + Audience string `json:"audience" toml:"audience" yaml:"audience"` +} + // JWTBasedAuthConfig holds the configuration for JWTBasedAuth validation. type JWTBasedAuthConfig struct { IssuerURL string @@ -49,7 +56,7 @@ type JWTBasedAuthConfig struct { // relevant to Vault request authorization. type JWTClaims struct { OrgID string - WorkflowOwner string // from authorization_details; may be empty for new JWT-only clients + WorkflowOwner string // from authorization_details RequestDigest string // from authorization_details ExpiresAt time.Time } @@ -84,7 +91,6 @@ type jwtBasedAuth struct { jwksURL string refreshInterval time.Duration authEnabledGate limits.GateLimiter - refreshEnabled bool mu sync.RWMutex keySet *jsonWebKeySet @@ -97,8 +103,7 @@ type jwtBasedAuth struct { } type jwtBasedAuthOptions struct { - authEnabledGate limits.GateLimiter - skipConfigChecks bool + authEnabledGate limits.GateLimiter } // JWTBasedAuthOption customizes JWTBasedAuth construction without multiplying constructors. @@ -111,14 +116,6 @@ func WithJWTBasedAuthGateLimiter(gateLimiter limits.GateLimiter) JWTBasedAuthOpt } } -// WithDisabledJWTBasedAuth makes the constructed JWTBasedAuth fail closed without requiring issuer config. -func WithDisabledJWTBasedAuth() JWTBasedAuthOption { - return func(opts *jwtBasedAuthOptions) { - opts.authEnabledGate = limits.NewGateLimiter(false) - opts.skipConfigChecks = true - } -} - // NewJWTBasedAuth creates a JWTBasedAuth authorizer that verifies Auth0-issued JWTs // against the provider's JWKS endpoint. The JWKS is fetched lazily on first // use and refreshed on key-ID cache misses (rate-limited). @@ -130,10 +127,10 @@ func NewJWTBasedAuth(cfg JWTBasedAuthConfig, limitsFactory limits.Factory, lggr if options.authEnabledGate == nil { options.authEnabledGate = newVaultJWTAuthEnabledGateLimiter(limitsFactory, lggr) } - if !options.skipConfigChecks && cfg.IssuerURL == "" { + if cfg.IssuerURL == "" { return nil, errors.New("issuer URL is required") } - if !options.skipConfigChecks && cfg.Audience == "" { + if cfg.Audience == "" { return nil, errors.New("audience is required") } @@ -156,7 +153,6 @@ func NewJWTBasedAuth(cfg JWTBasedAuthConfig, limitsFactory limits.Factory, lggr jwksURL: jwksURL, refreshInterval: refreshInterval, authEnabledGate: options.authEnabledGate, - refreshEnabled: !options.skipConfigChecks, httpClient: httpClient, lggr: logger.Named(lggr, "VaultJWTBasedAuth"), } @@ -180,11 +176,6 @@ func newVaultJWTAuthEnabledGateLimiter(limitsFactory limits.Factory, lggr logger } func (v *jwtBasedAuth) start(context.Context) error { - if !v.refreshEnabled { - v.lggr.Debug("JWTBasedAuth periodic JWKS refresh disabled") - return nil - } - v.eng.GoTick(services.NewTicker(v.refreshInterval), func(ctx context.Context) { if err := v.refreshJWKS(ctx); err != nil { v.lggr.Warnw("periodic JWKS refresh failed", "error", err) @@ -209,21 +200,21 @@ func (v *jwtBasedAuth) AuthorizeRequest(ctx context.Context, req jsonrpc.Request return nil, errors.New("JWTBasedAuth is disabled") } - requestDigest, err := req.Digest() - if err != nil { - v.lggr.Debugw("JWTBasedAuth failed to compute request digest", "method", req.Method, "requestID", req.ID, "error", err) - return nil, fmt.Errorf("failed to compute request digest: %w", err) - } - claims, err := v.validateToken(ctx, req.Auth) if err != nil { v.lggr.Debugw("JWTBasedAuth token validation failed", "method", req.Method, "requestID", req.ID, "error", err) return nil, fmt.Errorf("invalid JWT auth token: %w", err) } + requestDigest, err := req.Digest() + if err != nil { + v.lggr.Debugw("JWTBasedAuth failed to compute request digest", "method", req.Method, "requestID", req.ID, "orgID", claims.OrgID, "workflowOwner", claims.WorkflowOwner, "error", err) + return nil, fmt.Errorf("failed to compute request digest: %w", err) + } + if !strings.EqualFold(requestDigest, claims.RequestDigest) { v.lggr.Debugw("JWTBasedAuth request digest mismatch", "method", req.Method, "requestID", req.ID, "orgID", claims.OrgID, "workflowOwner", claims.WorkflowOwner, "computedDigest", requestDigest, "claimedDigest", claims.RequestDigest) - return nil, errors.New("request digest mismatch") + return nil, fmt.Errorf("request digest mismatch: computed=%s claimed=%s", requestDigest, claims.RequestDigest) } v.lggr.Debugw("JWTBasedAuth authorization succeeded", "method", req.Method, "requestID", req.ID, "orgID", claims.OrgID, "workflowOwner", claims.WorkflowOwner, "digest", requestDigest, "expiresAt", claims.ExpiresAt.UTC().Unix()) @@ -334,6 +325,9 @@ func extractAuthorizationDetails(claims jwt.MapClaims) (workflowOwner, requestDi if requestDigest == "" { return "", "", ErrMissingRequestDigest } + if workflowOwner == "" { + return "", "", ErrMissingWorkflowOwner + } return workflowOwner, requestDigest, nil } diff --git a/core/capabilities/vault/jwt_based_auth_test.go b/core/capabilities/vault/jwt_based_auth_test.go index a7e1c890379..6e5a62a27b6 100644 --- a/core/capabilities/vault/jwt_based_auth_test.go +++ b/core/capabilities/vault/jwt_based_auth_test.go @@ -166,7 +166,7 @@ func TestJWTBasedAuth_ValidToken(t *testing.T) { assert.False(t, result.ExpiresAt.IsZero()) } -func TestJWTBasedAuth_ValidToken_NoWorkflowOwner(t *testing.T) { +func TestJWTBasedAuth_RejectsTokenWithoutWorkflowOwner(t *testing.T) { rsaKey := generateTestRSAKey(t, "key-1") jwksServer := newTestJWKSServer(t, rsaKey) @@ -190,10 +190,8 @@ func TestJWTBasedAuth_ValidToken_NoWorkflowOwner(t *testing.T) { tokenString := createTestJWT(t, rsaKey, claims) result, err := v.validateToken(context.Background(), tokenString) - require.NoError(t, err) - assert.Equal(t, "org_no_wfowner", result.OrgID) - assert.Empty(t, result.WorkflowOwner) - assert.Equal(t, "digest456", result.RequestDigest) + require.Nil(t, result) + require.ErrorIs(t, err, ErrMissingWorkflowOwner) } func TestJWTBasedAuth_ExpiredToken(t *testing.T) { @@ -449,18 +447,6 @@ func TestJWTBasedAuth_StartRefreshesJWKSPeriodically(t *testing.T) { require.NoError(t, v.Close()) } -func TestJWTBasedAuth_DisabledStartSkipsPeriodicRefresh(t *testing.T) { - v, err := NewJWTBasedAuth( - JWTBasedAuthConfig{}, - limits.Factory{Settings: cresettings.DefaultGetter}, - logger.TestLogger(t), - WithDisabledJWTBasedAuth(), - ) - require.NoError(t, err) - require.NoError(t, v.Start(t.Context())) - require.NoError(t, v.Close()) -} - func TestNewJWTBasedAuth_InvalidConfig(t *testing.T) { lggr := logger.TestLogger(t) @@ -519,6 +505,48 @@ func TestNewJWTBasedAuth_UsesVaultJWTAuthEnabledLimiter_Enabled(t *testing.T) { require.ErrorContains(t, err, ErrMissingToken.Error()) } +func TestJWTBasedAuth_AuthorizeCreateRequestFromRawJSON(t *testing.T) { + rsaKey := generateTestRSAKey(t, "key-1") + jwksServer := newTestJWKSServer(t, rsaKey) + + issuer := jwksServer.URL() + "/" + audience := "https://vault.test.chain.link" + v := newTestValidator(t, issuer, audience) + + rawRequest := []byte(`{"jsonrpc":"2.0","id":"req-1","method":"vault.secrets.create","params":{"request_id":"req-1","encrypted_secrets":[{"id":{"key":"7611","namespace":"main","owner":"org-123"},"encrypted_value":"cipher+/=="}]}}`) + req, err := jsonrpc.DecodeRequest[json.RawMessage](rawRequest, "") + require.NoError(t, err) + + digest, err := req.Digest() + require.NoError(t, err) + + token := createTestJWT(t, rsaKey, jwt.MapClaims{ + "iss": issuer, + "aud": audience, + "exp": jwt.NewNumericDate(time.Now().Add(5 * time.Minute)), + "iat": jwt.NewNumericDate(time.Now()), + "org_id": "org-123", + "authorization_details": []interface{}{ + map[string]interface{}{ + "type": "request_digest", + "value": digest, + }, + map[string]interface{}{ + "type": "workflow_owner", + "value": "0xAbCdEf0123456789AbCdEf0123456789AbCdEf01", + }, + }, + }) + + req, err = jsonrpc.DecodeRequest[json.RawMessage](rawRequest, token) + require.NoError(t, err) + + authResult, err := v.AuthorizeRequest(t.Context(), req) + require.NoError(t, err) + require.Equal(t, "org-123", authResult.OrgID()) + require.Equal(t, digest, authResult.Digest()) +} + func setDefaultGetter(t *testing.T, payload string) { t.Helper() diff --git a/core/capabilities/vault/request_normalization.go b/core/capabilities/vault/request_normalization.go new file mode 100644 index 00000000000..9d0039cfd02 --- /dev/null +++ b/core/capabilities/vault/request_normalization.go @@ -0,0 +1,74 @@ +package vault + +import ( + "encoding/json" + + vaultcommon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault" + jsonrpc "github.com/smartcontractkit/chainlink-common/pkg/jsonrpc2" + "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes" +) + +// NormalizeRequestWithIdentity returns a copy of req with the supplied identity +// fields materialized into params for Vault secret-management methods. Requests +// with malformed params are returned unchanged so downstream parsing can surface +// the existing method-specific error paths. +func NormalizeRequestWithIdentity(req jsonrpc.Request[json.RawMessage], orgID, workflowOwner string) (jsonrpc.Request[json.RawMessage], error) { + if req.Params == nil { + return req, nil + } + + rewrite := func(payload any) error { + b, err := json.Marshal(payload) + if err != nil { + return err + } + raw := json.RawMessage(b) + req.Params = &raw + return nil + } + + switch req.Method { + case vaulttypes.MethodSecretsCreate: + parsed := &vaultcommon.CreateSecretsRequest{} + if err := json.Unmarshal(*req.Params, parsed); err != nil { + return req, nil + } + parsed.OrgId = orgID + parsed.WorkflowOwner = workflowOwner + return req, rewrite(parsed) + case vaulttypes.MethodSecretsUpdate: + parsed := &vaultcommon.UpdateSecretsRequest{} + if err := json.Unmarshal(*req.Params, parsed); err != nil { + return req, nil + } + parsed.OrgId = orgID + parsed.WorkflowOwner = workflowOwner + return req, rewrite(parsed) + case vaulttypes.MethodSecretsDelete: + parsed := &vaultcommon.DeleteSecretsRequest{} + if err := json.Unmarshal(*req.Params, parsed); err != nil { + return req, nil + } + parsed.OrgId = orgID + parsed.WorkflowOwner = workflowOwner + return req, rewrite(parsed) + case vaulttypes.MethodSecretsList: + parsed := &vaultcommon.ListSecretIdentifiersRequest{} + if err := json.Unmarshal(*req.Params, parsed); err != nil { + return req, nil + } + parsed.OrgId = orgID + parsed.WorkflowOwner = workflowOwner + return req, rewrite(parsed) + default: + return req, nil + } +} + +// StripRequestIdentity removes any org/workflow identity fields from Vault +// secret-management request params. This restores the request body shape used by +// the original client when the gateway had previously injected trusted identity +// fields for internal forwarding. +func StripRequestIdentity(req jsonrpc.Request[json.RawMessage]) (jsonrpc.Request[json.RawMessage], error) { + return NormalizeRequestWithIdentity(req, "", "") +} diff --git a/core/capabilities/vault/request_normalization_test.go b/core/capabilities/vault/request_normalization_test.go new file mode 100644 index 00000000000..c0573c048ce --- /dev/null +++ b/core/capabilities/vault/request_normalization_test.go @@ -0,0 +1,73 @@ +package vault + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + vaultcommon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault" + jsonrpc "github.com/smartcontractkit/chainlink-common/pkg/jsonrpc2" + "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes" +) + +func TestNormalizeRequestWithIdentity_RoundTripsCreateRequestIdentity(t *testing.T) { + req := mustVaultJSONRPCRequest(t, vaulttypes.MethodSecretsCreate, &vaultcommon.CreateSecretsRequest{ + RequestId: "req-1", + OrgId: "", + WorkflowOwner: "", + }) + + withIdentity, err := NormalizeRequestWithIdentity(req, "org-123", "0xowner") + require.NoError(t, err) + + parsedWithIdentity := mustParseCreateSecretsRequest(t, withIdentity) + require.Equal(t, "org-123", parsedWithIdentity.OrgId) + require.Equal(t, "0xowner", parsedWithIdentity.WorkflowOwner) + + stripped, err := StripRequestIdentity(withIdentity) + require.NoError(t, err) + + parsedStripped := mustParseCreateSecretsRequest(t, stripped) + require.Empty(t, parsedStripped.OrgId) + require.Empty(t, parsedStripped.WorkflowOwner) + require.Equal(t, "req-1", parsedStripped.RequestId) +} + +func TestStripRequestIdentity_LeavesMalformedParamsUnchanged(t *testing.T) { + raw := json.RawMessage(`{"not":"valid"`) + req := jsonrpc.Request[json.RawMessage]{ + Version: jsonrpc.JsonRpcVersion, + ID: "req-1", + Method: vaulttypes.MethodSecretsCreate, + Params: &raw, + } + + stripped, err := StripRequestIdentity(req) + require.NoError(t, err) + require.Equal(t, string(raw), string(*stripped.Params)) +} + +func mustVaultJSONRPCRequest(t *testing.T, method string, payload any) jsonrpc.Request[json.RawMessage] { + t.Helper() + + b, err := json.Marshal(payload) + require.NoError(t, err) + + raw := json.RawMessage(b) + return jsonrpc.Request[json.RawMessage]{ + Version: jsonrpc.JsonRpcVersion, + ID: "req-1", + Method: method, + Params: &raw, + } +} + +func mustParseCreateSecretsRequest(t *testing.T, req jsonrpc.Request[json.RawMessage]) *vaultcommon.CreateSecretsRequest { + t.Helper() + + parsed := &vaultcommon.CreateSecretsRequest{} + require.NotNil(t, req.Params) + require.NoError(t, json.Unmarshal(*req.Params, parsed)) + return parsed +} diff --git a/core/capabilities/vault/validator.go b/core/capabilities/vault/validator.go index e569c7d7d80..58592e6460a 100644 --- a/core/capabilities/vault/validator.go +++ b/core/capabilities/vault/validator.go @@ -23,16 +23,16 @@ type RequestValidator struct { } func (r *RequestValidator) ValidateCreateSecretsRequest(ctx context.Context, publicKey *tdh2easy.PublicKey, request *vaultcommon.CreateSecretsRequest) error { - return r.validateWriteRequest(ctx, publicKey, request.RequestId, request.EncryptedSecrets) + return r.validateWriteRequest(ctx, publicKey, request.RequestId, request.OrgId, request.WorkflowOwner, request.EncryptedSecrets) } func (r *RequestValidator) ValidateUpdateSecretsRequest(ctx context.Context, publicKey *tdh2easy.PublicKey, request *vaultcommon.UpdateSecretsRequest) error { - return r.validateWriteRequest(ctx, publicKey, request.RequestId, request.EncryptedSecrets) + return r.validateWriteRequest(ctx, publicKey, request.RequestId, request.OrgId, request.WorkflowOwner, request.EncryptedSecrets) } -// validateWriteRequest performs common validation for CreateSecrets and UpdateSecrets requests -// It treats publicKey as optional, since it can be nil if the gateway nodes don't have the public key cached yet -func (r *RequestValidator) validateWriteRequest(ctx context.Context, publicKey *tdh2easy.PublicKey, id string, encryptedSecrets []*vaultcommon.EncryptedSecret) error { +// validateWriteRequest performs common validation for CreateSecrets and UpdateSecrets requests. +// It treats publicKey as optional, since it can be nil if the gateway nodes don't have the public key cached yet. +func (r *RequestValidator) validateWriteRequest(ctx context.Context, publicKey *tdh2easy.PublicKey, id string, orgID string, workflowOwner string, encryptedSecrets []*vaultcommon.EncryptedSecret) error { if id == "" { return errors.New("request ID must not be empty") } @@ -66,7 +66,11 @@ func (r *RequestValidator) validateWriteRequest(ctx context.Context, publicKey * if err := r.validateCiphertextSize(ctx, req.EncryptedValue); err != nil { return fmt.Errorf("secret encrypted value at index %d is invalid: %w", idx, err) } - err := EnsureRightLabelOnSecret(publicKey, req.EncryptedValue, req.Id.Owner, "") + expectedWorkflowOwner := workflowOwner + if expectedWorkflowOwner == "" && orgID == "" { + expectedWorkflowOwner = req.Id.Owner + } + err := EnsureRightLabelOnSecret(publicKey, req.EncryptedValue, expectedWorkflowOwner, orgID) if err != nil { return errors.New("Encrypted Secret at index [" + strconv.Itoa(idx) + "] doesn't have owner as the label. Error: " + err.Error()) } diff --git a/core/capabilities/vault/validator_test.go b/core/capabilities/vault/validator_test.go index 8ac916ea499..dfbefe41076 100644 --- a/core/capabilities/vault/validator_test.go +++ b/core/capabilities/vault/validator_test.go @@ -298,3 +298,60 @@ func TestRequestValidator_CiphertextSizeLimit(t *testing.T) { }) } } + +func TestRequestValidator_ValidateCreateSecretsRequest_UsesRequestIdentityForOrgLabels(t *testing.T) { + pk, _ := generateTestKeys(t) + validator := NewRequestValidator( + limits.NewUpperBoundLimiter(10), + limits.NewUpperBoundLimiter[pkgconfig.Size](10*pkgconfig.KByte), + ) + + orgID := "org_2xAbCdEfGhIjKlMnOpQrStUvWxYz" + workflowOwner := "0x0001020304050607080900010203040506070809" + encrypted := encryptWithOrgIDLabel(t, pk, orgID) + + err := validator.ValidateCreateSecretsRequest(t.Context(), pk, &vaultcommon.CreateSecretsRequest{ + RequestId: "request-id", + OrgId: orgID, + WorkflowOwner: workflowOwner, + EncryptedSecrets: []*vaultcommon.EncryptedSecret{ + { + Id: &vaultcommon.SecretIdentifier{ + Key: "key", + Namespace: "namespace", + Owner: orgID, + }, + EncryptedValue: encrypted, + }, + }, + }) + + require.NoError(t, err) +} + +func TestRequestValidator_ValidateCreateSecretsRequest_FallsBackToSecretOwnerForLegacyRequests(t *testing.T) { + pk, _ := generateTestKeys(t) + validator := NewRequestValidator( + limits.NewUpperBoundLimiter(10), + limits.NewUpperBoundLimiter[pkgconfig.Size](10*pkgconfig.KByte), + ) + + workflowOwner := "0x0001020304050607080900010203040506070809" + encrypted := encryptWithEthAddressLabel(t, pk, workflowOwner) + + err := validator.ValidateCreateSecretsRequest(t.Context(), pk, &vaultcommon.CreateSecretsRequest{ + RequestId: "request-id", + EncryptedSecrets: []*vaultcommon.EncryptedSecret{ + { + Id: &vaultcommon.SecretIdentifier{ + Key: "key", + Namespace: "namespace", + Owner: workflowOwner, + }, + EncryptedValue: encrypted, + }, + }, + }) + + require.NoError(t, err) +} diff --git a/core/scripts/cre/environment/configs/capability_defaults.toml b/core/scripts/cre/environment/configs/capability_defaults.toml index 65fb75d3247..f04783f936c 100644 --- a/core/scripts/cre/environment/configs/capability_defaults.toml +++ b/core/scripts/cre/environment/configs/capability_defaults.toml @@ -105,6 +105,14 @@ OutgoingPerSenderBurst = 10 OutgoingPerSenderRPS = 10 +#[capability_configs.vault] +# no binary path for vault, it's built-in + +[capability_configs.vault.values] + [capability_configs.vault.values.auth0] + issuerURL = "http://host.docker.internal:18123/" + audience = "https://vault.test.chain.link" + [capability_configs.write-evm] # No binary_name needed - this is a built-in capability, it doesn't exist as a separate binary diff --git a/core/scripts/cre/environment/configs/workflow-gateway-legacy-vault-don.toml b/core/scripts/cre/environment/configs/workflow-gateway-capabilities-don-vault-jwt_auth-enabled.toml similarity index 65% rename from core/scripts/cre/environment/configs/workflow-gateway-legacy-vault-don.toml rename to core/scripts/cre/environment/configs/workflow-gateway-capabilities-don-vault-jwt_auth-enabled.toml index b352240acd3..7e332e45abb 100644 --- a/core/scripts/cre/environment/configs/workflow-gateway-legacy-vault-don.toml +++ b/core/scripts/cre/environment/configs/workflow-gateway-capabilities-don-vault-jwt_auth-enabled.toml @@ -1,22 +1,23 @@ -# NOTE: Identical to workflow-gatewway-capabilities.toml but with a vault capability config override -# to disable the new pending queue feature. +# Custom local CRE topology for Vault e2e coverage on top of workflow-gateway-capabilities-don. +# It enables the VaultJWTAuthEnabled and VaultOrgIdAsSecretOwnerEnabled CRE flags on workflow, capabilities, and gateway nodes. [chip_router] image = "local-cre-chip-router:v1.0.1" [[blockchains]] type = "anvil" chain_id = "1337" + container_name = "anvil-1337" docker_cmd_params = ["-b", "0.5", "--mixed-mining"] [[blockchains]] type = "anvil" chain_id = "2337" + container_name = "anvil-2337" port = "8546" docker_cmd_params = ["-b", "0.5", "--mixed-mining"] [jd] - csa_encryption_key = "d1093c0060d50a3c89c189b2e485da5a3ce57f3dcb38ab7e2c0d5f0bb2314a44" # any random 32 byte hex string - # change to your version + csa_encryption_key = "d1093c0060d50a3c89c189b2e485da5a3ce57f3dcb38ab7e2c0d5f0bb2314a44" image = "job-distributor:0.22.1" [fake] @@ -25,13 +26,7 @@ [fake_http] port = 8666 -#[s3provider] -# # use all defaults -# port = 9000 -# console_port = 9001 - [infra] - # either "docker" or "kubernetes" type = "docker" [[nodesets]] @@ -40,15 +35,10 @@ don_types = ["workflow"] override_mode = "all" http_port_range_start = 10100 - - # even though this DON is not using any capability for chain with ID 2337 we still need it to be connected to it, - # because bootstrap job for capability DON will be created on the boostrap node from this DON supported_evm_chains = [1337, 2337] - - env_vars = { CL_EVM_CMD = "", OTEL_SERVICE_NAME = "chainlink-node" } + env_vars = { CL_EVM_CMD = "", OTEL_SERVICE_NAME = "chainlink-node", CL_CRE_SETTINGS_DEFAULT = '{"VaultJWTAuthEnabled":"true","VaultOrgIdAsSecretOwnerEnabled":"true"}' } capabilities = ["ocr3", "custom-compute", "web-api-trigger", "cron", "http-action", "http-trigger", "consensus", "don-time", "write-evm-1337", "read-contract-1337", "evm-1337"] - - # See ./examples/workflow-don-overrides.toml to learn how to override capability configs + registry_based_launch_allowlist = ["cron-trigger@1.0.0"] [nodesets.db] image = "postgres:12.0" @@ -60,7 +50,6 @@ docker_ctx = "../../../.." docker_file = "core/chainlink.Dockerfile" docker_build_args = { "CL_IS_PROD_BUILD" = "false" } - # image = "chainlink-tmp:latest" user_config_overrides = "" [[nodesets]] @@ -70,18 +59,10 @@ exposes_remote_capabilities = true override_mode = "all" http_port_range_start = 10200 - - # we need to have chain 1337 configured (even if no capability uses it), because we use node addresses on chain 1337 - # to identify nodes in the gateway configuration (required by both web-api-target and vault capabilities) supported_evm_chains = [1337, 2337] - - env_vars = { CL_EVM_CMD = "", OTEL_SERVICE_NAME = "chainlink-node" } + env_vars = { CL_EVM_CMD = "", OTEL_SERVICE_NAME = "chainlink-node", CL_CRE_SETTINGS_DEFAULT = '{"VaultJWTAuthEnabled":"true","VaultOrgIdAsSecretOwnerEnabled":"true"}' } capabilities = ["web-api-target", "vault", "write-evm-2337", "read-contract-2337", "evm-2337"] - [nodesets.capability_configs] - [nodesets.capability_configs.vault.values] - EnableDeterministicPendingQueue = false # don't use the new pending queue feature for this DON. - [nodesets.db] image = "postgres:12.0" port = 13100 @@ -92,7 +73,6 @@ docker_ctx = "../../../.." docker_file = "core/chainlink.Dockerfile" docker_build_args = { "CL_IS_PROD_BUILD" = "false" } - # image = "chainlink-tmp:latest" user_config_overrides = "" [[nodesets]] @@ -101,8 +81,7 @@ don_types = ["bootstrap", "gateway"] override_mode = "each" http_port_range_start = 10300 - - env_vars = { CL_EVM_CMD = "", OTEL_SERVICE_NAME = "chainlink-node" } + env_vars = { CL_EVM_CMD = "", OTEL_SERVICE_NAME = "chainlink-node", CL_CRE_SETTINGS_DEFAULT = '{"VaultJWTAuthEnabled":"true","VaultOrgIdAsSecretOwnerEnabled":"true"}' } supported_evm_chains = [1337, 2337] [nodesets.db] @@ -115,8 +94,5 @@ docker_ctx = "../../../.." docker_file = "core/chainlink.Dockerfile" docker_build_args = { "CL_IS_PROD_BUILD" = "false" } - # 5002 is the web API capabilities port for incoming requests - # 15002 is the vault port for incoming requests custom_ports = ["5002:5002","15002:15002"] - # image = "chainlink-tmp:latest" user_config_overrides = "" diff --git a/core/scripts/cre/environment/docs/TOPOLOGIES.md b/core/scripts/cre/environment/docs/TOPOLOGIES.md index f6fd917157b..1f332a17dbf 100644 --- a/core/scripts/cre/environment/docs/TOPOLOGIES.md +++ b/core/scripts/cre/environment/docs/TOPOLOGIES.md @@ -6,11 +6,13 @@ This file is generated by `go run . topology generate`. Do not edit manually. |---|---|---:| | `configs/workflow-don-solana.toml` | `multi-don` | 3 ([details](topologies/workflow-don-solana.md)) | | `configs/workflow-don-tron.toml` | `single-don` | 2 ([details](topologies/workflow-don-tron.md)) | +| `configs/workflow-gateway-capabilities-don-vault-jwt_auth-enabled.toml` | `multi-don` | 3 ([details](topologies/workflow-gateway-capabilities-don-vault-jwt_auth-enabled.md)) | | `configs/workflow-gateway-capabilities-don.toml` | `multi-don` | 3 ([details](topologies/workflow-gateway-capabilities-don.md)) | +| `configs/workflow-gateway-don-aptos.toml` | `single-don` | 2 ([details](topologies/workflow-gateway-don-aptos.md)) | | `configs/workflow-gateway-don-grpc-source.toml` | `single-don` | 2 ([details](topologies/workflow-gateway-don-grpc-source.md)) | | `configs/workflow-gateway-don.toml` | `single-don` | 2 ([details](topologies/workflow-gateway-don.md)) | -| `configs/workflow-gateway-legacy-vault-don.toml` | `multi-don` | 3 ([details](topologies/workflow-gateway-legacy-vault-don.md)) | | `configs/workflow-gateway-mock-don.toml` | `multi-don` | 3 ([details](topologies/workflow-gateway-mock-don.md)) | +| `configs/workflow-gateway-sharded-5-dons.toml` | `sharded` | 7 ([details](topologies/workflow-gateway-sharded-5-dons.md)) | | `configs/workflow-gateway-sharded-don.toml` | `sharded` | 3 ([details](topologies/workflow-gateway-sharded-don.md)) | Tip: run `go run . topology list` for quick terminal guidance. diff --git a/core/scripts/cre/environment/docs/topologies/workflow-gateway-legacy-vault-don.md b/core/scripts/cre/environment/docs/topologies/workflow-gateway-capabilities-don-vault-jwt_auth-enabled.md similarity index 93% rename from core/scripts/cre/environment/docs/topologies/workflow-gateway-legacy-vault-don.md rename to core/scripts/cre/environment/docs/topologies/workflow-gateway-capabilities-don-vault-jwt_auth-enabled.md index 767d3e27fc4..69fdc5910db 100644 --- a/core/scripts/cre/environment/docs/topologies/workflow-gateway-legacy-vault-don.md +++ b/core/scripts/cre/environment/docs/topologies/workflow-gateway-capabilities-don-vault-jwt_auth-enabled.md @@ -1,6 +1,6 @@ # DON Topology -- Config: `configs/workflow-gateway-legacy-vault-don.toml` +- Config: `configs/workflow-gateway-capabilities-don-vault-jwt_auth-enabled.toml` - Class: `multi-don` - Infra: `docker` diff --git a/core/scripts/cre/environment/docs/topologies/workflow-gateway-don-aptos.md b/core/scripts/cre/environment/docs/topologies/workflow-gateway-don-aptos.md new file mode 100644 index 00000000000..7660a2b2c43 --- /dev/null +++ b/core/scripts/cre/environment/docs/topologies/workflow-gateway-don-aptos.md @@ -0,0 +1,34 @@ +# DON Topology + +- Config: `configs/workflow-gateway-don-aptos.toml` +- Class: `single-don` +- Infra: `docker` + +## Capability Matrix + +This matrix is the source of truth for capability placement by DON. + +| Capability | `bootstrap-gateway` | `workflow` | +|---|---|---| +| `aptos` | `-` | `local (4)` | +| `consensus` | `-` | `local` | +| `cron` | `-` | `local` | + +## DONs + +### `bootstrap-gateway` + +- Types: `bootstrap`, `gateway` +- Nodes: `1` +- Roles: `bootstrap`, `gateway` +- EVM chains: `1337` +- Exposes remote capabilities: `false` + +### `workflow` + +- Types: `workflow` +- Nodes: `4` +- Roles: `plugin` +- EVM chains: `1337` +- Exposes remote capabilities: `false` + diff --git a/core/scripts/cre/environment/docs/topologies/workflow-gateway-don-grpc-source.md b/core/scripts/cre/environment/docs/topologies/workflow-gateway-don-grpc-source.md index 12ed6c9eed4..3f9845a8068 100644 --- a/core/scripts/cre/environment/docs/topologies/workflow-gateway-don-grpc-source.md +++ b/core/scripts/cre/environment/docs/topologies/workflow-gateway-don-grpc-source.md @@ -41,5 +41,4 @@ This matrix is the source of truth for capability placement by DON. - Roles: `plugin` - EVM chains: `1337,2337` - Exposes remote capabilities: `false` -- Workflow additional sources: `enabled` diff --git a/core/scripts/cre/environment/docs/topologies/workflow-gateway-sharded-5-dons.md b/core/scripts/cre/environment/docs/topologies/workflow-gateway-sharded-5-dons.md new file mode 100644 index 00000000000..d5e054d27c9 --- /dev/null +++ b/core/scripts/cre/environment/docs/topologies/workflow-gateway-sharded-5-dons.md @@ -0,0 +1,84 @@ +# DON Topology + +- Config: `configs/workflow-gateway-sharded-5-dons.toml` +- Class: `sharded` +- Infra: `docker` + +## Capability Matrix + +This matrix is the source of truth for capability placement by DON. + +| Capability | `bootstrap-gateway` | `shard0` | `shard1` | `shard2` | `shard3` | `shard4` | `shard5` | +|---|---|---|---|---|---|---|---| +| `consensus` | `-` | `local` | `local` | `local` | `local` | `local` | `local` | +| `cron` | `-` | `local` | `local` | `local` | `local` | `local` | `local` | +| `custom-compute` | `-` | `local` | `local` | `local` | `local` | `local` | `local` | +| `don-time` | `-` | `local` | `local` | `local` | `local` | `local` | `local` | +| `evm` | `-` | `local (1337,2337)` | `local (1337,2337)` | `local (1337,2337)` | `local (1337,2337)` | `local (1337,2337)` | `local (1337,2337)` | +| `http-action` | `-` | `local` | `-` | `-` | `-` | `-` | `-` | +| `http-trigger` | `-` | `local` | `-` | `-` | `-` | `-` | `-` | +| `ocr3` | `-` | `local` | `local` | `local` | `local` | `local` | `local` | +| `read-contract` | `-` | `local (1337,2337)` | `local (1337,2337)` | `local (1337,2337)` | `local (1337,2337)` | `local (1337,2337)` | `local (1337,2337)` | +| `vault` | `-` | `local` | `-` | `-` | `-` | `-` | `-` | +| `web-api-target` | `-` | `local` | `local` | `local` | `local` | `local` | `local` | +| `web-api-trigger` | `-` | `local` | `local` | `local` | `local` | `local` | `local` | +| `write-evm` | `-` | `local (1337,2337)` | `local (1337,2337)` | `local (1337,2337)` | `local (1337,2337)` | `local (1337,2337)` | `local (1337,2337)` | + +## DONs + +### `bootstrap-gateway` + +- Types: `bootstrap`, `gateway` +- Nodes: `1` +- Roles: `bootstrap`, `gateway` +- EVM chains: `1337,2337` +- Exposes remote capabilities: `false` + +### `shard0` + +- Types: `shard`, `workflow` +- Nodes: `4` +- Roles: `plugin` +- EVM chains: `1337,2337` +- Exposes remote capabilities: `false` + +### `shard1` + +- Types: `shard`, `workflow` +- Nodes: `4` +- Roles: `plugin` +- EVM chains: `1337,2337` +- Exposes remote capabilities: `false` + +### `shard2` + +- Types: `shard`, `workflow` +- Nodes: `4` +- Roles: `plugin` +- EVM chains: `1337,2337` +- Exposes remote capabilities: `false` + +### `shard3` + +- Types: `shard`, `workflow` +- Nodes: `4` +- Roles: `plugin` +- EVM chains: `1337,2337` +- Exposes remote capabilities: `false` + +### `shard4` + +- Types: `shard`, `workflow` +- Nodes: `4` +- Roles: `plugin` +- EVM chains: `1337,2337` +- Exposes remote capabilities: `false` + +### `shard5` + +- Types: `shard`, `workflow` +- Nodes: `4` +- Roles: `plugin` +- EVM chains: `1337,2337` +- Exposes remote capabilities: `false` + diff --git a/core/services/gateway/handlers/vault/handler.go b/core/services/gateway/handlers/vault/handler.go index 54d09b89653..b063ba94adf 100644 --- a/core/services/gateway/handlers/vault/handler.go +++ b/core/services/gateway/handlers/vault/handler.go @@ -175,24 +175,36 @@ type SecretEntry struct { type Config struct { NodeRateLimiter ratelimit.RateLimiterConfig `json:"nodeRateLimiter"` RequestTimeoutSec int `json:"requestTimeoutSec"` + Auth0 *vaultcap.Auth0Config `json:"auth0,omitempty"` } // NewHandler creates the gateway-side Vault handler with internal auth wiring. func NewHandler(methodConfig json.RawMessage, donConfig *config.DONConfig, don gwhandlers.DON, capabilitiesRegistry capabilitiesRegistry, workflowRegistrySyncer workflowsyncerv2.WorkflowRegistrySyncer, lggr logger.Logger, clock clockwork.Clock, limitsFactory limits.Factory) (*handler, error) { + var cfg Config + if err := json.Unmarshal(methodConfig, &cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal method config: %w", err) + } + allowListBasedAuth := vaultcap.NewAllowListBasedAuth(lggr, workflowRegistrySyncer) - jwtBasedAuth, err := vaultcap.NewJWTBasedAuth(vaultcap.JWTBasedAuthConfig{}, limitsFactory, lggr, vaultcap.WithDisabledJWTBasedAuth()) - if err != nil { - return nil, fmt.Errorf("failed to create JWTBasedAuth: %w", err) + var jwtBasedAuth vaultcap.Authorizer + var jwtAuth services.Service + if cfg.Auth0 != nil { + validator, err := vaultcap.NewJWTBasedAuth(vaultcap.JWTBasedAuthConfig{ + IssuerURL: cfg.Auth0.IssuerURL, + Audience: cfg.Auth0.Audience, + }, limitsFactory, lggr) + if err != nil { + return nil, fmt.Errorf("failed to create JWTBasedAuth: %w", err) + } + jwtBasedAuth = validator + jwtAuth = validator } authorizer := vaultcap.NewAuthorizer(allowListBasedAuth, jwtBasedAuth, lggr) - return newHandlerWithJWTAuth(methodConfig, donConfig, don, capabilitiesRegistry, authorizer, jwtBasedAuth, lggr, clock, limitsFactory) -} -func newHandlerWithAuthorizer(methodConfig json.RawMessage, donConfig *config.DONConfig, don gwhandlers.DON, capabilitiesRegistry capabilitiesRegistry, authorizer vaultcap.Authorizer, lggr logger.Logger, clock clockwork.Clock, limitsFactory limits.Factory) (*handler, error) { - return newHandlerWithJWTAuth(methodConfig, donConfig, don, capabilitiesRegistry, authorizer, nil, lggr, clock, limitsFactory) + return newHandlerWithAuthorizer(methodConfig, donConfig, don, capabilitiesRegistry, authorizer, jwtAuth, lggr, clock, limitsFactory) } -func newHandlerWithJWTAuth(methodConfig json.RawMessage, donConfig *config.DONConfig, don gwhandlers.DON, capabilitiesRegistry capabilitiesRegistry, authorizer vaultcap.Authorizer, jwtAuth services.Service, lggr logger.Logger, clock clockwork.Clock, limitsFactory limits.Factory) (*handler, error) { +func newHandlerWithAuthorizer(methodConfig json.RawMessage, donConfig *config.DONConfig, don gwhandlers.DON, capabilitiesRegistry capabilitiesRegistry, authorizer vaultcap.Authorizer, jwtAuth services.Service, lggr logger.Logger, clock clockwork.Clock, limitsFactory limits.Factory) (*handler, error) { var cfg Config if err := json.Unmarshal(methodConfig, &cfg); err != nil { return nil, fmt.Errorf("failed to unmarshal method config: %w", err) @@ -397,11 +409,17 @@ func (h *handler) HandleJSONRPCUserMessage(ctx context.Context, req jsonrpc.Requ return h.handlePublicKeyGetSynchronously(ctx, req, publicKeyResponseBytes, callback) } - authResult, err := h.authorizer.AuthorizeRequest(ctx, req) - if err != nil { - h.lggr.Errorw("request not authorized", "method", req.Method, "requestID", req.ID, "hasAuth", req.Auth != "", "error", err) - return errors.New("request not authorized: " + err.Error()) + authResult, authErr := h.authorizer.AuthorizeRequest(ctx, req) + if authErr != nil { + h.lggr.Errorw("request not authorized", "method", req.Method, "requestID", req.ID, "hasAuth", req.Auth != "", "error", authErr) + return errors.New("request not authorized: " + authErr.Error()) } + normalizedReq, normalizeErr := vaultcap.NormalizeRequestWithIdentity(req, authResult.OrgID(), authResult.WorkflowOwner()) + if normalizeErr != nil { + h.lggr.Errorw("failed to normalize authorized request identity", "method", req.Method, "requestID", req.ID, "orgID", authResult.OrgID(), "workflowOwner", authResult.WorkflowOwner(), "error", normalizeErr) + return normalizeErr + } + req = normalizedReq authorizedOwner := authResult.AuthorizedOwner() // Generate a unique ID for the request. // Prefix request id with authorizedOwner, to ensure uniqueness across different owners @@ -409,9 +427,9 @@ func (h *handler) HandleJSONRPCUserMessage(ctx context.Context, req jsonrpc.Requ req.ID = authorizedOwner + vaulttypes.RequestIDSeparator + req.ID h.lggr.Debugw("handling authorized vault request", "method", req.Method, "requestID", req.ID, "authorizedOwner", authorizedOwner) - ar, err := h.newActiveRequest(req, callback) - if err != nil { - return err + ar, activeRequestErr := h.newActiveRequest(req, callback) + if activeRequestErr != nil { + return activeRequestErr } switch req.Method { diff --git a/core/services/gateway/handlers/vault/handler_test.go b/core/services/gateway/handlers/vault/handler_test.go index 91a3df576ee..3c06ea9a906 100644 --- a/core/services/gateway/handlers/vault/handler_test.go +++ b/core/services/gateway/handlers/vault/handler_test.go @@ -61,10 +61,8 @@ func setupHandler(t *testing.T) (handlers.Handler, *common.Callback, *mocks.DON, clock := clockwork.NewFakeClock() limitsFactory := limits.Factory{Settings: cresettings.DefaultGetter} - jwtBasedAuth, err := vaultcap.NewJWTBasedAuth(vaultcap.JWTBasedAuthConfig{}, limitsFactory, lggr, vaultcap.WithDisabledJWTBasedAuth()) - require.NoError(t, err) - authorizer := vaultcap.NewAuthorizer(&stubAllowListBasedAuth{clock: clock}, jwtBasedAuth, lggr) - handler, err := newHandlerWithAuthorizer(methodConfig, donConfig, don, nil, authorizer, lggr, clock, limitsFactory) + authorizer := vaultcap.NewAuthorizer(&stubAllowListBasedAuth{clock: clock}, nil, lggr) + handler, err := newHandlerWithAuthorizer(methodConfig, donConfig, don, nil, authorizer, nil, lggr, clock, limitsFactory) require.NoError(t, err) handler.aggregator = &mockAggregator{} cb := common.NewCallback() @@ -79,6 +77,15 @@ func (s *stubAllowListBasedAuth) AuthorizeRequest(_ context.Context, req jsonrpc return vaultcap.NewAuthResult("", owner, "digest-"+req.ID, s.clock.Now().Add(time.Minute).Unix()), nil } +type stubAuthorizer struct { + result *vaultcap.AuthResult + err error +} + +func (s *stubAuthorizer) AuthorizeRequest(_ context.Context, _ jsonrpc.Request[json.RawMessage]) (*vaultcap.AuthResult, error) { + return s.result, s.err +} + type mockAggregator struct { err error } @@ -219,6 +226,79 @@ func TestVaultHandler_HandleJSONRPCUserMessage(t *testing.T) { wg.Wait() }) + t.Run("overwrites request identity fields after authorization", func(t *testing.T) { + lggr := logger.Test(t) + don := mocks.NewDON(t) + donConfig := &config.DONConfig{ + DonId: "test_don_id", + Members: []config.NodeConfig{NodeOne}, + } + handlerConfig := Config{ + RequestTimeoutSec: 30, + NodeRateLimiter: ratelimit.RateLimiterConfig{ + GlobalRPS: 100, + GlobalBurst: 100, + PerSenderRPS: 10, + PerSenderBurst: 10, + }, + } + methodConfig, err := json.Marshal(handlerConfig) + require.NoError(t, err) + + clock := clockwork.NewFakeClock() + limitsFactory := limits.Factory{Settings: cresettings.DefaultGetter} + h, err := newHandlerWithAuthorizer( + methodConfig, + donConfig, + don, + nil, + &stubAuthorizer{result: vaultcap.NewAuthResult("org-1", "0xworkflow", "digest-1", clock.Now().Add(time.Minute).Unix())}, + nil, + lggr, + clock, + limitsFactory, + ) + require.NoError(t, err) + + forgedCreateSecretsRequest := &vaultcommon.CreateSecretsRequest{ + RequestId: "test_request_id", + OrgId: "forged-org", + WorkflowOwner: "0xforged", + EncryptedSecrets: []*vaultcommon.EncryptedSecret{ + { + Id: &vaultcommon.SecretIdentifier{ + Key: "test_id", + Owner: "org-1", + }, + EncryptedValue: "abc123", + }, + }, + } + requestParams, err := json.Marshal(forgedCreateSecretsRequest) + require.NoError(t, err) + + var forwarded jsonrpc.Request[json.RawMessage] + don.On("SendToNode", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + forwarded = *args.Get(2).(*jsonrpc.Request[json.RawMessage]) + }).Return(nil) + + req := jsonrpc.Request[json.RawMessage]{ + ID: "1", + Method: vaulttypes.MethodSecretsCreate, + Params: (*json.RawMessage)(&requestParams), + } + + err = h.HandleJSONRPCUserMessage(t.Context(), req, common.NewCallback()) + require.NoError(t, err) + + require.NotNil(t, forwarded.Params) + var forwardedCreateRequest vaultcommon.CreateSecretsRequest + require.NoError(t, json.Unmarshal(*forwarded.Params, &forwardedCreateRequest)) + require.Equal(t, "org-1", forwardedCreateRequest.OrgId) + require.Equal(t, "0xworkflow", forwardedCreateRequest.WorkflowOwner) + require.Equal(t, "org-1"+vaulttypes.RequestIDSeparator+"1", forwardedCreateRequest.RequestId) + }) + t.Run("nil EncryptedSecrets inside CreateSecrets body", func(t *testing.T) { var wg sync.WaitGroup h, callback, _, _ := setupHandler(t) diff --git a/core/services/ocr2/delegate.go b/core/services/ocr2/delegate.go index 7180c63ab08..ad6751f51c8 100644 --- a/core/services/ocr2/delegate.go +++ b/core/services/ocr2/delegate.go @@ -676,7 +676,7 @@ func (d *Delegate) newServicesVaultPlugin( } srvs = append(srvs, vaultCapability) - handler, err := vaultcap.NewGatewayHandler(vaultCapability, gwconnector, syncer, d.lggr, limitsFactory) + handler, err := vaultcap.NewGatewayHandler(vaultCapability, gwconnector, syncer, d.lggr, limitsFactory, nil, cfg.Auth0) if err != nil { return nil, fmt.Errorf("failed to instantiate vault plugin: failed to create vault handler: %w", err) } diff --git a/core/services/ocr2/plugins/vault/config.go b/core/services/ocr2/plugins/vault/config.go index ba00b2eed4a..28985b64f58 100644 --- a/core/services/ocr2/plugins/vault/config.go +++ b/core/services/ocr2/plugins/vault/config.go @@ -4,6 +4,7 @@ import ( "errors" commonconfig "github.com/smartcontractkit/chainlink-common/pkg/config" + vaultcap "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault" ) type DKGConfig struct { @@ -13,6 +14,7 @@ type DKGConfig struct { type Config struct { RequestExpiryDuration commonconfig.Duration `json:"requestExpiryDuration"` DKG *DKGConfig `json:"dkg,omitempty"` + Auth0 *vaultcap.Auth0Config `json:"auth0,omitempty"` } func (c *Config) Validate() error { diff --git a/core/services/ocr2/plugins/vault/plugin.go b/core/services/ocr2/plugins/vault/plugin.go index c8f7f8c0dbb..53785b47f96 100644 --- a/core/services/ocr2/plugins/vault/plugin.go +++ b/core/services/ocr2/plugins/vault/plugin.go @@ -390,6 +390,25 @@ func (r *ReportingPlugin) orgIDAsSecretOwnerEnabled(ctx context.Context) bool { return r.cfg.OrgIDAsSecretOwnerEnabled.AllowErr(ctx) == nil } +// canonicalResponseID rewrites successful CRUD responses to the canonical owner identity. +// +// When VaultOrgIdAsSecretOwnerEnabled is on, requests may still arrive keyed by +// workflow owner for backwards compatibility with existing clients and allowlist-based +// flows. The server persists and reasons about the canonical owner as org_id though, +// so responses should expose that canonical org owner instead of echoing the +// workflow-owner request key back to the client. +func (r *ReportingPlugin) canonicalResponseID(ctx context.Context, id *vaultcommon.SecretIdentifier, orgID string) *vaultcommon.SecretIdentifier { + if id == nil || orgID == "" || !r.orgIDAsSecretOwnerEnabled(ctx) { + return id + } + + return &vaultcommon.SecretIdentifier{ + Key: id.Key, + Namespace: id.Namespace, + Owner: orgID, + } +} + type pendingQueueStore interface { WritePendingQueue(ctx context.Context, pending []*vaultcommon.StoredPendingQueueItem) error } @@ -1911,7 +1930,7 @@ func (r *ReportingPlugin) stateTransitionCreateSecrets(ctx context.Context, stor }) continue } - resp, err := r.stateTransitionCreateSecretsRequest(ctx, store, req, resp) + resp, err := r.stateTransitionCreateSecretsRequest(ctx, store, req, resp, first.GetCreateSecretsRequest().OrgId) if err != nil { logUserErrorAware(r.lggr, "failed to handle create secret request", err, "id", req.Id, "requestID", reqID) errorMsg := userFacingError(err, "failed to handle create secret request") @@ -1933,7 +1952,7 @@ func (r *ReportingPlugin) stateTransitionCreateSecrets(ctx context.Context, stor } } -func (r *ReportingPlugin) stateTransitionCreateSecretsRequest(ctx context.Context, store WriteKVStore, req *vaultcommon.EncryptedSecret, resp *vaultcommon.CreateSecretResponse) (*vaultcommon.CreateSecretResponse, error) { +func (r *ReportingPlugin) stateTransitionCreateSecretsRequest(ctx context.Context, store WriteKVStore, req *vaultcommon.EncryptedSecret, resp *vaultcommon.CreateSecretResponse, orgID string) (*vaultcommon.CreateSecretResponse, error) { if resp.GetError() != "" { return resp, newUserError(resp.GetError()) } @@ -1974,7 +1993,7 @@ func (r *ReportingPlugin) stateTransitionCreateSecretsRequest(ctx context.Contex } return &vaultcommon.CreateSecretResponse{ - Id: req.Id, + Id: r.canonicalResponseID(ctx, req.Id, orgID), Success: true, Error: "", }, nil @@ -2029,7 +2048,7 @@ func (r *ReportingPlugin) stateTransitionUpdateSecrets(ctx context.Context, stor }) continue } - resp, err := r.stateTransitionUpdateSecretsRequest(ctx, store, req, resp) + resp, err := r.stateTransitionUpdateSecretsRequest(ctx, store, req, resp, first.GetUpdateSecretsRequest().OrgId) if err != nil { logUserErrorAware(r.lggr, "failed to handle update secret request", err, "id", req.Id, "requestID", reqID) errorMsg := userFacingError(err, "failed to handle update secret request") @@ -2051,7 +2070,7 @@ func (r *ReportingPlugin) stateTransitionUpdateSecrets(ctx context.Context, stor } } -func (r *ReportingPlugin) stateTransitionUpdateSecretsRequest(ctx context.Context, store WriteKVStore, req *vaultcommon.EncryptedSecret, resp *vaultcommon.UpdateSecretResponse) (*vaultcommon.UpdateSecretResponse, error) { +func (r *ReportingPlugin) stateTransitionUpdateSecretsRequest(ctx context.Context, store WriteKVStore, req *vaultcommon.EncryptedSecret, resp *vaultcommon.UpdateSecretResponse, orgID string) (*vaultcommon.UpdateSecretResponse, error) { if resp.GetError() != "" { return resp, newUserError(resp.GetError()) } @@ -2078,7 +2097,7 @@ func (r *ReportingPlugin) stateTransitionUpdateSecretsRequest(ctx context.Contex } return &vaultcommon.UpdateSecretResponse{ - Id: req.Id, + Id: r.canonicalResponseID(ctx, req.Id, orgID), Success: true, Error: "", }, nil @@ -2133,7 +2152,7 @@ func (r *ReportingPlugin) stateTransitionDeleteSecrets(ctx context.Context, stor }) continue } - resp, err := r.stateTransitionDeleteSecretsRequest(ctx, store, req, resp) + resp, err := r.stateTransitionDeleteSecretsRequest(ctx, store, req, resp, first.GetDeleteSecretsRequest().OrgId) if err != nil { logUserErrorAware(r.lggr, "failed to handle delete secret request", err, "id", id, "requestId", reqID) errorMsg := userFacingError(err, "failed to handle delete secret request") @@ -2155,7 +2174,7 @@ func (r *ReportingPlugin) stateTransitionDeleteSecrets(ctx context.Context, stor } } -func (r *ReportingPlugin) stateTransitionDeleteSecretsRequest(ctx context.Context, store WriteKVStore, id *vaultcommon.SecretIdentifier, resp *vaultcommon.DeleteSecretResponse) (*vaultcommon.DeleteSecretResponse, error) { +func (r *ReportingPlugin) stateTransitionDeleteSecretsRequest(ctx context.Context, store WriteKVStore, id *vaultcommon.SecretIdentifier, resp *vaultcommon.DeleteSecretResponse, orgID string) (*vaultcommon.DeleteSecretResponse, error) { if resp.GetError() != "" { return resp, newUserError(resp.GetError()) } @@ -2166,7 +2185,7 @@ func (r *ReportingPlugin) stateTransitionDeleteSecretsRequest(ctx context.Contex } return &vaultcommon.DeleteSecretResponse{ - Id: id, + Id: r.canonicalResponseID(ctx, id, orgID), Success: true, Error: "", }, nil diff --git a/core/services/ocr2/plugins/vault/plugin_test.go b/core/services/ocr2/plugins/vault/plugin_test.go index 9a5b37d4cd3..96df7cebd24 100644 --- a/core/services/ocr2/plugins/vault/plugin_test.go +++ b/core/services/ocr2/plugins/vault/plugin_test.go @@ -3723,6 +3723,114 @@ func TestPlugin_StateTransition_CreateSecretsRequest_UsesWorkflowOwnerMetadataWh assert.Nil(t, ss) } +func TestPlugin_StateTransition_CreateSecretsRequest_RewritesResponseOwnerToOrgIDWhenGateEnabled(t *testing.T) { + lggr, observed := logger.TestLoggerObserved(t, zapcore.DebugLevel) + store := requests.NewStore[*vaulttypes.Request]() + _, pk, shares, err := tdh2easy.GenerateKeys(1, 3) + require.NoError(t, err) + cfg := makeReportingPluginConfig( + t, + 10, + pk, + shares[0], + 5, + 1024, + 100, + 100, + 100, + 10, + ) + cfg.OrgIDAsSecretOwnerEnabled = limits.NewGateLimiter(true) + r := &ReportingPlugin{ + lggr: lggr, + onchainCfg: ocr3types.ReportingPluginConfig{ + N: 4, + F: 1, + }, + store: store, + metrics: newTestMetrics(t), + cfg: cfg, + } + + const ( + orgID = "org-create-success" + workflowOwner = "0x5555555555555555555555555555555555555555" + ) + + requestID := &vaultcommon.SecretIdentifier{ + Owner: workflowOwner, + Namespace: "main", + Key: "secret", + } + canonicalID := &vaultcommon.SecretIdentifier{ + Owner: orgID, + Namespace: "main", + Key: "secret", + } + + value := []byte("encrypted-value") + req := &vaultcommon.CreateSecretsRequest{ + RequestId: "request-id", + EncryptedSecrets: []*vaultcommon.EncryptedSecret{ + { + Id: requestID, + EncryptedValue: hex.EncodeToString(value), + }, + }, + OrgId: orgID, + WorkflowOwner: workflowOwner, + } + resp := &vaultcommon.CreateSecretsResponse{ + Responses: []*vaultcommon.CreateSecretResponse{ + { + Id: requestID, + Success: false, + Error: "", + }, + }, + } + + kv := &kv{m: make(map[string]response)} + rs := newTestReadStore(t, kv) + obsb := marshalObservations(t, observation{requestID, req, resp}) + reportPrecursor, err := r.StateTransition( + t.Context(), + 1, + types.AttributedQuery{}, + []types.AttributedObservation{ + {Observer: 0, Observation: types.Observation(obsb)}, + {Observer: 1, Observation: types.Observation(obsb)}, + {Observer: 2, Observation: types.Observation(obsb)}, + }, + kv, + nil, + ) + require.NoError(t, err) + + os := &vaultcommon.Outcomes{} + require.NoError(t, proto.Unmarshal(reportPrecursor, os)) + require.Len(t, os.Outcomes, 1) + + o := os.Outcomes[0] + assert.True(t, proto.Equal(req, o.GetCreateSecretsRequest())) + expectedResp := &vaultcommon.CreateSecretsResponse{ + Responses: []*vaultcommon.CreateSecretResponse{ + { + Id: canonicalID, + Success: true, + Error: "", + }, + }, + } + assert.True(t, proto.Equal(expectedResp, o.GetCreateSecretsResponse()), o.GetCreateSecretsResponse()) + + ss, err := rs.GetSecret(t.Context(), canonicalID) + require.NoError(t, err) + assert.Equal(t, []byte("encrypted-value"), ss.EncryptedSecret) + + assert.Equal(t, 1, observed.FilterMessage("sufficient observations for sha").Len()) +} + func TestPlugin_Reports(t *testing.T) { value := "encrypted-value" id := &vaultcommon.SecretIdentifier{ @@ -4533,7 +4641,7 @@ func TestPlugin_StateTransition_UpdateSecretsRequest_MigratesWorkflowOwnerSecret RequestId: "request-id", EncryptedSecrets: []*vaultcommon.EncryptedSecret{ { - Id: id, + Id: legacyID, EncryptedValue: hex.EncodeToString([]byte("encrypted-value")), }, }, @@ -4543,14 +4651,14 @@ func TestPlugin_StateTransition_UpdateSecretsRequest_MigratesWorkflowOwnerSecret resp := &vaultcommon.UpdateSecretsResponse{ Responses: []*vaultcommon.UpdateSecretResponse{ { - Id: id, + Id: legacyID, Success: false, Error: "", }, }, } - obsb := marshalObservations(t, observation{id, req, resp}) + obsb := marshalObservations(t, observation{legacyID, req, resp}) reportPrecursor, err := r.StateTransition( t.Context(), 1, @@ -4573,6 +4681,7 @@ func TestPlugin_StateTransition_UpdateSecretsRequest_MigratesWorkflowOwnerSecret assert.True(t, proto.Equal(req, o.GetUpdateSecretsRequest()), o.GetUpdateSecretsRequest()) require.Len(t, o.GetUpdateSecretsResponse().Responses, 1) assert.True(t, o.GetUpdateSecretsResponse().Responses[0].Success) + assert.Equal(t, orgID, o.GetUpdateSecretsResponse().Responses[0].Id.Owner) ss, err := rs.GetSecret(t.Context(), id) require.NoError(t, err) @@ -5059,21 +5168,21 @@ func TestPlugin_StateTransition_DeleteSecretsRequest_DeletesWorkflowOwnerSecretW } req := &vaultcommon.DeleteSecretsRequest{ RequestId: "request-id", - Ids: []*vaultcommon.SecretIdentifier{id}, + Ids: []*vaultcommon.SecretIdentifier{legacyID}, OrgId: orgID, WorkflowOwner: workflowOwner, } resp := &vaultcommon.DeleteSecretsResponse{ Responses: []*vaultcommon.DeleteSecretResponse{ { - Id: id, + Id: legacyID, Success: false, Error: "", }, }, } - obsb := marshalObservations(t, observation{id, req, resp}) + obsb := marshalObservations(t, observation{legacyID, req, resp}) reportPrecursor, err := r.StateTransition( t.Context(), 1, @@ -5096,6 +5205,7 @@ func TestPlugin_StateTransition_DeleteSecretsRequest_DeletesWorkflowOwnerSecretW assert.True(t, proto.Equal(req, o.GetDeleteSecretsRequest()), o.GetDeleteSecretsRequest()) require.Len(t, o.GetDeleteSecretsResponse().Responses, 1) assert.True(t, o.GetDeleteSecretsResponse().Responses[0].Success) + assert.True(t, proto.Equal(id, o.GetDeleteSecretsResponse().Responses[0].Id), o.GetDeleteSecretsResponse().Responses[0].Id) ss, err := rs.GetSecret(t.Context(), legacyID) require.NoError(t, err) diff --git a/deployment/cre/jobs/operations/propose_gateway_job.go b/deployment/cre/jobs/operations/propose_gateway_job.go index 239b6dc0f15..f0b946d063e 100644 --- a/deployment/cre/jobs/operations/propose_gateway_job.go +++ b/deployment/cre/jobs/operations/propose_gateway_job.go @@ -42,9 +42,10 @@ type DON struct { } type GatewayService struct { - ServiceName string `yaml:"servicename"` - Handlers []string `yaml:"handlers"` - DONs []string `yaml:"dons"` + ServiceName string `yaml:"servicename"` + Handlers []string `yaml:"handlers"` + DONs []string `yaml:"dons"` + Auth0 *pkg.Auth0Config `yaml:"auth0,omitempty"` } type ProposeGatewayJobDeps struct { @@ -172,6 +173,7 @@ func buildServiceCentricJob(deps ProposeGatewayJobDeps, input ProposeGatewayJobI ServiceName: svc.ServiceName, Handlers: svc.Handlers, DONs: svc.DONs, + Auth0: svc.Auth0, } } diff --git a/deployment/cre/jobs/operations/propose_ocr3_job.go b/deployment/cre/jobs/operations/propose_ocr3_job.go index ce3c781d514..85048c14e06 100644 --- a/deployment/cre/jobs/operations/propose_ocr3_job.go +++ b/deployment/cre/jobs/operations/propose_ocr3_job.go @@ -36,6 +36,7 @@ type ProposeOCR3JobInput struct { // Optionals: specific to the worker vault OCR3 Job spec DKGContractAddress string VaultRequestExpiryDuration string + Auth0 *pkg.Auth0Config DONFilters []offchain.TargetDONFilter ExtraLabels map[string]string @@ -81,7 +82,7 @@ var ProposeOCR3Job = operations.NewSequence[ProposeOCR3JobInput, ProposeOCR3JobO specs, err := pkg.BuildOCR3JobConfigSpecs( deps.Env.Offchain, deps.Env.Logger, input.ContractAddress, input.ChainSelectorEVM, - input.ChainSelectorAptos, input.ChainSelectorSolana, nodes, input.BootstrapperOCR3Urls, input.DONName, input.JobName, input.TemplateName, input.DKGContractAddress, vaultReqExpiry, + input.ChainSelectorAptos, input.ChainSelectorSolana, nodes, input.BootstrapperOCR3Urls, input.DONName, input.JobName, input.TemplateName, input.DKGContractAddress, vaultReqExpiry, input.Auth0, ) if err != nil { return ProposeOCR3JobOutput{}, fmt.Errorf("failed to build OCR3 job config specs: %w", err) diff --git a/deployment/cre/jobs/pkg/gateway_job.go b/deployment/cre/jobs/pkg/gateway_job.go index 3bc6828cac1..a546c944609 100644 --- a/deployment/cre/jobs/pkg/gateway_job.go +++ b/deployment/cre/jobs/pkg/gateway_job.go @@ -53,6 +53,7 @@ type GatewayServiceConfig struct { ServiceName string Handlers []string DONs []string + Auth0 *Auth0Config } type GatewayJob struct { @@ -227,7 +228,7 @@ func (g GatewayJob) buildLegacyDons() ([]legacyDON, error) { case GatewayHandlerTypeWebAPICapabilities: hs = append(hs, newDefaultWebAPICapabilitiesHandler()) case GatewayHandlerTypeVault: - hs = append(hs, newDefaultVaultHandler(g.RequestTimeoutSec)) + hs = append(hs, newDefaultVaultHandler(g.RequestTimeoutSec, nil)) case GatewayHandlerTypeHTTPCapabilities: hs = append(hs, newDefaultHTTPCapabilitiesHandler()) case GatewayHandlerTypeConfidentialRelay: @@ -271,7 +272,7 @@ func (g GatewayJob) buildServicesAndShardedDONs() ([]shardedDON, []service, erro case GatewayHandlerTypeWebAPICapabilities: handlers = append(handlers, newDefaultWebAPICapabilitiesHandler()) case GatewayHandlerTypeVault: - handlers = append(handlers, newDefaultVaultHandler(g.RequestTimeoutSec)) + handlers = append(handlers, newDefaultVaultHandler(g.RequestTimeoutSec, svcCfg.Auth0)) case GatewayHandlerTypeHTTPCapabilities: handlers = append(handlers, newDefaultHTTPCapabilitiesHandler()) case GatewayHandlerTypeConfidentialRelay: @@ -314,9 +315,10 @@ func newDefaultWebAPICapabilitiesHandler() handler { type vaultHandlerConfig struct { RequestTimeoutSec int `toml:"requestTimeoutSec"` NodeRateLimiter nodeRateLimiterConfig `toml:"NodeRateLimiter"` + Auth0 *Auth0Config `toml:"auth0,omitempty"` } -func newDefaultVaultHandler(requestTimeoutSec int) handler { +func newDefaultVaultHandler(requestTimeoutSec int, auth0 *Auth0Config) handler { return handler{ Name: GatewayHandlerTypeVault, ServiceName: ServiceNameVault, @@ -330,6 +332,7 @@ func newDefaultVaultHandler(requestTimeoutSec int) handler { PerSenderBurst: 10, PerSenderRPS: 10, }, + Auth0: auth0, }, } } diff --git a/deployment/cre/jobs/pkg/ocr3_job.go b/deployment/cre/jobs/pkg/ocr3_job.go index 37960d90427..c5993a5556e 100644 --- a/deployment/cre/jobs/pkg/ocr3_job.go +++ b/deployment/cre/jobs/pkg/ocr3_job.go @@ -35,6 +35,12 @@ type OCR3JobConfigInput struct { // Optionals: specific to the worker vault OCR3 Job spec DKGContractQualifier string `yaml:"dkgContractQualifier"` VaultRequestExpiryDuration string `yaml:"vaultRequestExpiryDuration"` + Auth0 *Auth0Config +} + +type Auth0Config struct { + IssuerURL string `yaml:"issuerURL" toml:"issuerURL" json:"issuerURL"` + Audience string `yaml:"audience" toml:"audience" json:"audience"` } type OCR3JobConfig struct { @@ -52,6 +58,7 @@ type OCR3JobConfig struct { DKGContractAddress string VaultRequestExpiryDuration string + Auth0 *Auth0Config } func (c OCR3JobConfig) Validate() error { @@ -92,6 +99,14 @@ func (c OCR3JobConfig) Validate() error { if err != nil { return fmt.Errorf("VaultRequestExpiryDuration is not a valid duration: %w", err) } + if c.Auth0 != nil { + if c.Auth0.IssuerURL == "" { + return errors.New("Auth0.IssuerURL is required for worker-vault template when auth0 is configured") + } + if c.Auth0.Audience == "" { + return errors.New("Auth0.Audience is required for worker-vault template when auth0 is configured") + } + } } return nil @@ -129,6 +144,7 @@ func BuildOCR3JobConfigSpecs( donName, jobName, templateName string, dkgContractAddress string, vaultRequestExpiryDuration string, + auth0 *Auth0Config, ) ([]OCR3JobConfigSpec, error) { nodesLen := len(nodes) if nodesLen == 0 { @@ -196,6 +212,7 @@ func BuildOCR3JobConfigSpecs( TemplateName: templateName, DKGContractAddress: dkgContractAddress, VaultRequestExpiryDuration: vaultRequestExpiryDuration, + Auth0: auth0, } err1 := jobConfig.Validate() diff --git a/deployment/cre/jobs/pkg/templates/worker-vault.tmpl b/deployment/cre/jobs/pkg/templates/worker-vault.tmpl index 442b35afcaf..49a4025a93b 100644 --- a/deployment/cre/jobs/pkg/templates/worker-vault.tmpl +++ b/deployment/cre/jobs/pkg/templates/worker-vault.tmpl @@ -16,3 +16,8 @@ chainID = "{{ .ChainID }}" requestExpiryDuration = "{{ .VaultRequestExpiryDuration }}" [pluginConfig.dkg] dkgContractID = "{{ .DKGContractAddress }}" +{{- if .Auth0 }} +[pluginConfig.auth0] +issuerURL = "{{ .Auth0.IssuerURL }}" +audience = "{{ .Auth0.Audience }}" +{{- end }} diff --git a/deployment/cre/jobs/propose_job_spec.go b/deployment/cre/jobs/propose_job_spec.go index 3a80678fdae..5502941e093 100644 --- a/deployment/cre/jobs/propose_job_spec.go +++ b/deployment/cre/jobs/propose_job_spec.go @@ -196,6 +196,7 @@ func (u ProposeJobSpec) Apply(e cldf.Environment, input ProposeJobSpecInput) (cl BootstrapperOCR3Urls: jobInput.BootstrapperOCR3Urls, DKGContractAddress: dkgContractAddr, VaultRequestExpiryDuration: jobInput.VaultRequestExpiryDuration, + Auth0: jobInput.Auth0, DONFilters: input.DONFilters, ExtraLabels: input.ExtraLabels, }, diff --git a/docs/local-cre/agent-skills/local-cre-e2e/AGENTS.md b/docs/local-cre/agent-skills/local-cre-e2e/AGENTS.md new file mode 100644 index 00000000000..3e087b45bff --- /dev/null +++ b/docs/local-cre/agent-skills/local-cre-e2e/AGENTS.md @@ -0,0 +1,22 @@ +# Local CRE E2E Agent Notes + +This directory holds a reusable agent skill for running Local CRE and CRE e2e tests. + +## Scope + +These instructions apply only to files under `docs/local-cre/agent-skills/local-cre-e2e/`. + +## Intent + +- Keep the skill practical and workflow-oriented. +- Favor commands that work from a clean checkout. +- Prefer the default topology unless the user explicitly needs a different topology or a custom override. +- Keep topology customization guidance minimal and repo-specific. + +## When Updating The Skill + +- Keep the skill aligned with: + - `docs/local-cre/` + - `core/scripts/cre/environment/configs/` +- If test commands or topology selection rules change, update this skill in the same PR when possible. +- Do not add generic AI-agent boilerplate; keep it specific to Local CRE usage in this repo. diff --git a/docs/local-cre/agent-skills/local-cre-e2e/SKILL.md b/docs/local-cre/agent-skills/local-cre-e2e/SKILL.md new file mode 100644 index 00000000000..ed58a2077d9 --- /dev/null +++ b/docs/local-cre/agent-skills/local-cre-e2e/SKILL.md @@ -0,0 +1,220 @@ +--- +name: local-cre-e2e +description: Configure and run local CRE environments and CRE end-to-end tests in the chainlink repo. Use this when starting local CRE on the default topology, running smoke or regression CRE tests, or creating a custom topology to override flags, limits, capability config, or user config overrides. +--- + +# Local CRE E2E + +Use this skill when working in the `chainlink` repo and you need to: +- start, stop, or restart local CRE +- run CRE smoke or regression e2e tests on the default topology +- run a test against a specific topology +- create a custom topology to override limits, flags, capability config, or user config overrides + +This skill is for local CRE system-test workflows, not for generic unit tests. + +## Assumptions + +- Repo root is the `chainlink` checkout. +- Local CRE commands are run from `core/scripts/cre/environment`. +- CRE e2e test commands are run from `system-tests/tests`. +- Only one local CRE environment should be treated as active at a time unless the harness is explicitly known to support isolation. + +## Default Workflow + +Use the default topology when the user asks to run the standard local CRE suite or to verify a change without special flags. + +1. Stop any existing local CRE environment: + +```bash +cd core/scripts/cre/environment +go run . env stop -a +``` + +2. If the environment has not been prepared yet, set it up once: + +```bash +cd core/scripts/cre/environment +go run . env setup +``` + +3. Start local CRE on the default topology: + +```bash +cd core/scripts/cre/environment +go run . env start +``` + +4. Optionally bring up observability helpers: + +```bash +go run . obs up +``` + +Use `--with-beholder` on `env start` only when the test depends on the real Beholder stack or when you want Red Panda Console to debug workflow events. + +## Running E2E Tests On The Default Topology + +For the normal CRE smoke suite: + +```bash +cd system-tests/tests +go test ./smoke/cre -timeout 20m -run '^Test_CRE_' +``` + +For only the V2 smoke suite: + +```bash +cd system-tests/tests +go test ./smoke/cre -timeout 15m -run '^Test_CRE_V2' +``` + +For regression tests: + +```bash +cd system-tests/tests +go test ./regression/cre -timeout 20m -run '^Test_CRE_' +``` + +Rule of thumb: +- `smoke` is for happy-path and sanity coverage +- `regression` is for edge cases and negative cases + +## Running A Specific Test Or Bucket + +Use a narrow regex when debugging a single scenario or bucket: + +```bash +cd system-tests/tests +go test ./smoke/cre -timeout 20m -run '^Test_CRE_V2_Suite_Bucket_B$' -count=1 -v +``` + +Examples: + +```bash +cd system-tests/tests +go test ./smoke/cre -timeout 20m -run 'Test_CRE_V2_Suite_Bucket_B/.*/Vault' -count=1 -v +``` + +```bash +cd system-tests/tests +go test ./regression/cre -timeout 20m -run '^Test_CRE_V2_Consensus_Regression$' -count=1 -v +``` + +Prefer `-count=1` when re-running flaky or stateful CRE scenarios. + +## Using A Specific Topology + +Use a non-default topology when the test requires a specific DON layout, chain, or feature configuration. + +1. Stop the existing environment: + +```bash +cd core/scripts/cre/environment +go run . env stop -a +``` + +2. Start local CRE with `CTF_CONFIGS` pointing at the topology file: + +```bash +cd core/scripts/cre/environment +CTF_CONFIGS=./configs/workflow-gateway-capabilities-don.toml go run . env start +``` + +3. Run the target test: + +```bash +cd system-tests/tests +TOPOLOGY_NAME=workflow-gateway-capabilities \ +go test ./smoke/cre -timeout 20m -run '^Test_CRE_V2_Suite_Bucket_B$' -count=1 -v +``` + +`TOPOLOGY_NAME` is optional but useful because many CRE suite tests include it in subtest names. + +## Creating A Custom Topology + +Create a custom topology when the user wants to override: +- limits +- feature flags +- capability config +- DON composition +- additional sources +- `user_config_overrides` + +Workflow: + +1. Pick the closest existing topology from `core/scripts/cre/environment/configs/`. +2. Copy it to a new file in the same directory. +3. Change only the fields needed for the scenario. +4. Start local CRE with `CTF_CONFIGS=`. +5. Run only the relevant tests first. + +Example: + +```bash +cd core/scripts/cre/environment/configs +cp workflow-gateway-capabilities-don.toml workflow-gateway-capabilities-don-my-override.toml +``` + +Then start it: + +```bash +cd ../ +CTF_CONFIGS=./configs/workflow-gateway-capabilities-don-my-override.toml go run . env start +``` + +Then run the intended tests: + +```bash +cd ../../../system-tests/tests +TOPOLOGY_NAME=workflow-gateway-capabilities-my-override \ +go test ./smoke/cre -timeout 20m -run '^Test_CRE_V2_Suite_Bucket_B$' -count=1 -v +``` + +## Override Guidelines + +When making a custom topology: +- keep the diff small and purpose-specific +- prefer copying the nearest topology instead of building a new one from scratch +- use a descriptive filename that states what changed +- do not change unrelated images, chains, or capabilities unless the test needs it +- if the topology is only for a one-off local check, keep it local and avoid adding it to CI + +Typical override points: +- `nodesets.capability_configs` +- `nodesets.user_config_overrides` +- CRE feature flags +- additional mock or support-service endpoints + +## Restart And Cleanup + +When changing topology or low-level config, prefer a full stop/start instead of assuming the running environment will converge. + +Clean restart: + +```bash +cd core/scripts/cre/environment +go run . env stop -a +CTF_CONFIGS=./configs/.toml go run . env start +``` + +When done: + +```bash +cd core/scripts/cre/environment +go run . env stop -a +``` + +## Troubleshooting + +- If tests unexpectedly use the wrong topology, stop local CRE and restart with the intended `CTF_CONFIGS`. +- If the test suite appears to reuse stale state, rerun with `-count=1`. +- If a test depends on logs, traces, or dashboards, bring up `go run . obs up`. +- If a topology-specific failure looks unrelated to the test, first confirm the environment actually started with the intended topology. + +## References + +For longer repo-specific guidance, see: +- `docs/local-cre/index.md` +- `docs/local-cre/system-tests/index.md` +- `docs/local-cre/system-tests/running-tests.md` diff --git a/docs/local-cre/index.md b/docs/local-cre/index.md index f351f849d9c..e5f1593106b 100644 --- a/docs/local-cre/index.md +++ b/docs/local-cre/index.md @@ -16,6 +16,7 @@ Use this doc set when you need to: - deploy or debug workflows - run or extend CRE smoke tests - understand how the test helpers interact with a running Local CRE environment +- reuse repo-local agent guidance for Local CRE under `docs/local-cre/agent-skills/local-cre-e2e/` ## Start Here diff --git a/system-tests/lib/cre/environment/config/config.go b/system-tests/lib/cre/environment/config/config.go index ef1ceb9044f..4ea96dae7a6 100644 --- a/system-tests/lib/cre/environment/config/config.go +++ b/system-tests/lib/cre/environment/config/config.go @@ -174,6 +174,8 @@ func (c *Config) Load(absPath string) error { return errors.Wrap(loadErr, "failed to load environment configuration") } + transformHostDockerInternalReferences(in) + for _, nodeSet := range in.NodeSets { if err := nodeSet.ValidateChainCapabilities(in.Blockchains); err != nil { return errors.Wrap(err, "failed to validate chain capabilities") @@ -186,6 +188,78 @@ func (c *Config) Load(absPath string) error { return nil } +func transformHostDockerInternalReferences(cfg *Config) { + if cfg == nil { + return + } + + for _, nodeSet := range cfg.NodeSets { + if nodeSet == nil { + continue + } + + for _, nodeSpec := range nodeSet.NodeSpecs { + if nodeSpec == nil || nodeSpec.Node == nil || nodeSpec.Node.UserConfigOverrides == "" { + continue + } + nodeSpec.Node.UserConfigOverrides = replaceHostDockerInternal(nodeSpec.Node.UserConfigOverrides) + } + + transformCapabilityConfigs(nodeSet.CapabilityConfigs) + } + + transformCapabilityConfigs(cfg.CapabilityConfigs) +} + +func transformCapabilityConfigs(capabilityConfigs map[string]cre.CapabilityConfig) { + if len(capabilityConfigs) == 0 { + return + } + + for key, cfg := range capabilityConfigs { + cfg.Values = transformCapabilityConfigValues(cfg.Values) + capabilityConfigs[key] = cfg + } +} + +func transformCapabilityConfigValues(values map[string]any) map[string]any { + if len(values) == 0 { + return values + } + + transformed := make(map[string]any, len(values)) + for key, value := range values { + transformed[key] = transformCapabilityConfigValue(value) + } + + return transformed +} + +func transformCapabilityConfigValue(value any) any { + switch typed := value.(type) { + case string: + return replaceHostDockerInternal(typed) + case map[string]any: + return transformCapabilityConfigValues(typed) + case []any: + transformed := make([]any, len(typed)) + for i, element := range typed { + transformed[i] = transformCapabilityConfigValue(element) + } + return transformed + default: + return value + } +} + +func replaceHostDockerInternal(value string) string { + if value == "" { + return value + } + + return strings.ReplaceAll(value, "host.docker.internal", strings.TrimPrefix(framework.HostDockerInternal(), "http://")) +} + const ( StateDirname = "core/scripts/cre/environment/state" LocalCREStateFilename = "local_cre.toml" diff --git a/system-tests/lib/cre/environment/config/config_test.go b/system-tests/lib/cre/environment/config/config_test.go new file mode 100644 index 00000000000..004196d3622 --- /dev/null +++ b/system-tests/lib/cre/environment/config/config_test.go @@ -0,0 +1,60 @@ +package config + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-testing-framework/framework" + "github.com/smartcontractkit/chainlink-testing-framework/framework/components/clnode" + + "github.com/smartcontractkit/chainlink/system-tests/lib/cre" +) + +func TestTransformHostDockerInternalReferences(t *testing.T) { + t.Parallel() + + dockerHost := strings.TrimPrefix(framework.HostDockerInternal(), "http://") + cfg := &Config{ + NodeSets: []*cre.NodeSet{ + { + NodeSpecs: []*cre.NodeSpecWithRole{ + { + Input: &clnode.Input{ + Node: &clnode.NodeInput{}, + }, + }, + }, + CapabilityConfigs: map[cre.CapabilityFlag]cre.CapabilityConfig{ + cre.VaultCapability: { + Values: map[string]any{ + "auth0": map[string]any{ + "issuerURL": "http://host.docker.internal:18123/", + "urls": []any{"host.docker.internal:18124"}, + }, + }, + }, + }, + }, + }, + CapabilityConfigs: map[string]cre.CapabilityConfig{ + cre.VaultCapability: { + Values: map[string]any{ + "endpoint": "host.docker.internal:9999", + }, + }, + }, + } + cfg.NodeSets[0].NodeSpecs[0].Node.UserConfigOverrides = "[CRE.Linking]\nURL = \"host.docker.internal:18124\"\n" + + transformHostDockerInternalReferences(cfg) + + require.Contains(t, cfg.NodeSets[0].NodeSpecs[0].Node.UserConfigOverrides, dockerHost+":18124") + + auth0 := cfg.NodeSets[0].CapabilityConfigs[cre.VaultCapability].Values["auth0"].(map[string]any) + require.Equal(t, framework.HostDockerInternal()+":18123/", auth0["issuerURL"]) + require.Equal(t, []any{dockerHost + ":18124"}, auth0["urls"]) + + require.Equal(t, dockerHost+":9999", cfg.CapabilityConfigs[cre.VaultCapability].Values["endpoint"]) +} diff --git a/system-tests/lib/cre/features/vault/vault.go b/system-tests/lib/cre/features/vault/vault.go index ce6d9d11530..0a7cc67ebff 100644 --- a/system-tests/lib/cre/features/vault/vault.go +++ b/system-tests/lib/cre/features/vault/vault.go @@ -3,8 +3,11 @@ package vault import ( "context" "encoding/hex" + "encoding/json" "fmt" + "slices" "strconv" + "strings" "time" "dario.cat/mergo" @@ -20,6 +23,7 @@ import ( "github.com/smartcontractkit/smdkg/dkgocr/dkgocrtypes" "github.com/smartcontractkit/tdh2/go/tdh2/tdh2easy" + "github.com/smartcontractkit/chainlink-testing-framework/framework" "github.com/smartcontractkit/chainlink-testing-framework/lib/utils/ptr" depcontracts "github.com/smartcontractkit/chainlink/deployment/cre/ocr3/ocr3_1/changeset/operations/contracts" "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaultutils" @@ -55,6 +59,10 @@ const ( type Vault struct{} +type runtimeConfig struct { + Auth0 *cre.GatewayServiceAuth0Config `json:"auth0"` +} + func (o *Vault) Flag() cre.CapabilityFlag { return flag } @@ -66,6 +74,11 @@ func (o *Vault) PreEnvStartup( topology *cre.Topology, creEnv *cre.Environment, ) (*cre.PreEnvStartupOutput, error) { + auth0Config, cfgErr := resolveRuntimeConfig(don.MustNodeSet()) + if cfgErr != nil { + return nil, errors.Wrap(cfgErr, "failed to resolve vault runtime config") + } + // use registry chain, because that is the chain we used when generating gateway connector part of node config (check below) registryChainID, chErr := chainselectors.ChainIdFromSelector(creEnv.RegistryChainSelector) if chErr != nil { @@ -78,6 +91,11 @@ func (o *Vault) PreEnvStartup( if hErr != nil { return nil, errors.Wrapf(hErr, "failed to add gateway handlers to gateway config for don %s ", don.Name) } + if auth0Config.Auth0 != nil { + if err := applyGatewayAuth0Config(topology, don.Name, auth0Config.Auth0); err != nil { + return nil, errors.Wrapf(err, "failed to apply auth0 gateway config for don %s", don.Name) + } + } cErr := don.ConfigureForGatewayAccess(registryChainID, *topology.GatewayConnectors) if cErr != nil { @@ -86,19 +104,21 @@ func (o *Vault) PreEnvStartup( workflowRegistryAddress := contracts.MustGetAddressFromDataStore(creEnv.CldfEnvironment.DataStore, creEnv.RegistryChainSelector, keystone_changeset.WorkflowRegistry.String(), creEnv.ContractVersions[keystone_changeset.WorkflowRegistry.String()], "") - // enable workflow registry syncer in node's TOML config - workerNodes, wErr := don.Workers() - if wErr != nil { - return nil, errors.Wrap(wErr, "failed to find worker nodes") + donsToConfigure := []*cre.DonMetadata{don} + workflowDONs, wfErr := topology.DonsMetadata.WorkflowDONs() + if wfErr == nil { + for _, workflowDON := range workflowDONs { + if workflowDON.ID == don.ID { + continue + } + donsToConfigure = append(donsToConfigure, workflowDON) + } } - for _, workerNode := range workerNodes { - currentConfig := don.MustNodeSet().NodeSpecs[workerNode.Index].Node.TestConfigOverrides - updatedConfig, uErr := updateNodeConfig(workerNode, currentConfig, registryChainID, common.HexToAddress(workflowRegistryAddress), creEnv.ContractVersions[keystone_changeset.WorkflowRegistry.String()]) - if uErr != nil { - return nil, errors.Wrapf(uErr, "failed to update node config for node index %d", workerNode.Index) + for _, donToConfigure := range donsToConfigure { + if err := configureWorkersNodeConfig(donToConfigure, registryChainID, common.HexToAddress(workflowRegistryAddress), creEnv.ContractVersions[keystone_changeset.WorkflowRegistry.String()]); err != nil { + return nil, err } - don.MustNodeSet().NodeSpecs[workerNode.Index].Node.TestConfigOverrides = *updatedConfig } capabilities := []keystone_changeset.DONCapabilityWithConfig{{ @@ -133,6 +153,10 @@ func updateNodeConfig(workerNode *cre.NodeMetadata, currentConfig string, regist SyncStrategy: ptr.Ptr("reconciliation"), ContractVersion: ptr.Ptr(wfRegVersion.String()), } + typedConfig.CRE.Linking = &coretoml.LinkingConfig{ + URL: ptr.Ptr(strings.TrimPrefix(framework.HostDockerInternal(), "http://") + ":18124"), + TLSEnabled: ptr.Ptr(false), + } stringifiedConfig, mErr := toml.Marshal(typedConfig) if mErr != nil { @@ -142,6 +166,24 @@ func updateNodeConfig(workerNode *cre.NodeMetadata, currentConfig string, regist return ptr.Ptr(string(stringifiedConfig)), nil } +func configureWorkersNodeConfig(don *cre.DonMetadata, registryChainID uint64, workflowRegistryAddress common.Address, wfRegVersion *semver.Version) error { + workerNodes, wErr := don.Workers() + if wErr != nil { + return errors.Wrapf(wErr, "failed to find worker nodes for don %s", don.Name) + } + + for _, workerNode := range workerNodes { + currentConfig := don.MustNodeSet().NodeSpecs[workerNode.Index].Node.TestConfigOverrides + updatedConfig, uErr := updateNodeConfig(workerNode, currentConfig, registryChainID, workflowRegistryAddress, wfRegVersion) + if uErr != nil { + return errors.Wrapf(uErr, "failed to update node config for don %s node index %d", don.Name, workerNode.Index) + } + don.MustNodeSet().NodeSpecs[workerNode.Index].Node.TestConfigOverrides = *updatedConfig + } + + return nil +} + func (o *Vault) PostEnvStartup( ctx context.Context, testLogger zerolog.Logger, @@ -254,6 +296,15 @@ func createJobs( don *cre.Don, dons *cre.Dons, ) error { + auth0Config := &runtimeConfig{} + if capConfig, ok := don.GetCapabilityConfig(flag); ok { + var err error + auth0Config, err = decodeRuntimeConfig(capConfig.Values) + if err != nil { + return fmt.Errorf("failed to resolve vault runtime config: %w", err) + } + } + bootstrap, isBootstrap := dons.Bootstrap() if !isBootstrap { return errors.New("could not find bootstrap node in topology, exactly one bootstrap node is required") @@ -284,6 +335,9 @@ func createJobs( "bootstrapperOCR3Urls": []string{ocrPeeringCfg.OCRBootstraperPeerID + "@" + ocrPeeringCfg.OCRBootstraperHost + ":" + strconv.Itoa(ocrPeeringCfg.Port)}, }, } + if auth0Config.Auth0 != nil { + workerInput.Inputs["auth0"] = auth0Config.Auth0 + } workerVerErr := cre_jobs.ProposeJobSpec{}.VerifyPreconditions(*creEnv.CldfEnvironment, workerInput) if workerVerErr != nil { @@ -314,6 +368,61 @@ func createJobs( return nil } +func resolveRuntimeConfig(nodeSet *cre.NodeSet) (*runtimeConfig, error) { + if nodeSet == nil { + return &runtimeConfig{}, nil + } + + capConfig, ok := nodeSet.GetCapabilityConfig(flag) + if !ok || len(capConfig.Values) == 0 { + return &runtimeConfig{}, nil + } + + return decodeRuntimeConfig(capConfig.Values) +} + +func decodeRuntimeConfig(values map[string]any) (*runtimeConfig, error) { + if len(values) == 0 { + return &runtimeConfig{}, nil + } + + b, err := json.Marshal(values) + if err != nil { + return nil, fmt.Errorf("failed to marshal vault capability values: %w", err) + } + + cfg := &runtimeConfig{} + if err := json.Unmarshal(b, cfg); err != nil { + return nil, fmt.Errorf("failed to decode vault capability values: %w", err) + } + + return cfg, nil +} + +func applyGatewayAuth0Config(topology *cre.Topology, donName string, auth0 *cre.GatewayServiceAuth0Config) error { + if topology == nil || auth0 == nil { + return nil + } + + for idx := range topology.GatewayServiceConfigs { + svc := &topology.GatewayServiceConfigs[idx] + if svc.ServiceName != pkg.ServiceNameVault || !slices.Contains(svc.DONs, donName) { + continue + } + if svc.Auth0 != nil && (svc.Auth0.IssuerURL != auth0.IssuerURL || svc.Auth0.Audience != auth0.Audience) { + return fmt.Errorf("vault gateway service %q already has conflicting auth0 config", svc.ServiceName) + } + + svc.Auth0 = &cre.GatewayServiceAuth0Config{ + IssuerURL: auth0.IssuerURL, + Audience: auth0.Audience, + } + return nil + } + + return fmt.Errorf("vault gateway service config not found for DON %s", donName) +} + func deployVaultContracts(testLogger zerolog.Logger, qualifier string, registryChainSelector uint64, env *cldf.Environment, contractVersions map[cre.ContractType]*semver.Version) (*common.Address, *common.Address, error) { memoryDatastore, mErr := contracts.NewDataStoreFromExisting(env.DataStore) if mErr != nil { diff --git a/system-tests/lib/cre/types.go b/system-tests/lib/cre/types.go index 9b8ad4c517c..6069223bed0 100644 --- a/system-tests/lib/cre/types.go +++ b/system-tests/lib/cre/types.go @@ -430,10 +430,16 @@ func (c *ConfigureCapabilityRegistryInput) Validate() error { // GatewayServiceConfig represents a service in the service-centric gateway format. // Each service groups handlers and references the DON names it operates on. +type GatewayServiceAuth0Config struct { + IssuerURL string `yaml:"issuerURL" toml:"issuerURL" json:"issuerURL"` + Audience string `yaml:"audience" toml:"audience" json:"audience"` +} + type GatewayServiceConfig struct { - ServiceName string `yaml:"servicename"` - Handlers []string `yaml:"handlers"` - DONs []string `yaml:"dons"` + ServiceName string `yaml:"servicename"` + Handlers []string `yaml:"handlers"` + DONs []string `yaml:"dons"` + Auth0 *GatewayServiceAuth0Config `yaml:"auth0,omitempty"` } type GatewayConnectors struct { diff --git a/system-tests/lib/cre/vault/jwt_auth.go b/system-tests/lib/cre/vault/jwt_auth.go new file mode 100644 index 00000000000..5a6c44553e4 --- /dev/null +++ b/system-tests/lib/cre/vault/jwt_auth.go @@ -0,0 +1,429 @@ +package vault + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "math/big" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" + + jsonrpc "github.com/smartcontractkit/chainlink-common/pkg/jsonrpc2" + "github.com/smartcontractkit/chainlink-testing-framework/framework" +) + +const ( + DefaultJWTIssuerKeyID = "vault-jwt-test-key" + DefaultJWTAudience = "https://vault.test.chain.link" + DefaultJWTLifetime = 5 * time.Minute + defaultJWTPrivateKeyPEM = `-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDYhEVPZ8YdC3Va +DGZ2hWPt+VYptOt0heTulBOwBW0ESavpfvokLYGFu+bLkGhIw365nCFw0eulLZYN +tD4nzq7F5Swtb2iIaDK19PBVNcukU/CY6j44KC1eomyaOvPXKWKwcc7qxjy9bIyA +TyOmOlxNxcNRSjL2SOApFkzb8M/RymHlMT/RY5ubytvjcbQgn2gy19U7HuNLYW1P +gviAAMY635u0A+HAxXx83lQSz9gy08/uBarmKAd2OadCA8cNiTSYyfUS6m1pycA7 +j8ZHY75xL4hm+p2PJd9V1x3Z4S1TpZDIj+YAG/v4ZHB1vLTLoPIgwLEqwGRRWijl +sbdUZRd9AgMBAAECggEAGCiWFTiWheof43bLvgC/OC/gedHajctc0nQKSFMqqVZR +DMIixgOf1pyzMVaBFFFf4/T0VELQAMO34PqSDt4EaUdbaQxrxQCfW+cjI9bXTJQj +HeTRIXH2Mf98j67xQzo2bUqdlFufLmGcwbpS13rejrz4wKq/SfSyslLvK4FQpu8x +5J9ntn2wdgeUQCm62FyuNPxFMBldcovnwf9bbojTjMAatWfyF++W8OAcRqZCab1H +1WNPyhBqG5vDVMtgBdTkwZHqI01B+ozMnBLuEhsLVzvQWE79ZouWtU76GIeFlr0n +bC/3uWq9LBo1kEbLIPucxYA14ytWfpQwUvy1k11s4QKBgQD4dz2fVYSVb6hn0Pon +EQtunruNB7F2JlobY2s3C7aBKs+l48J16whKFcqHUA6NpuSvyUhFTqIpxM0LXdar +6nWu4Yw0kbqACJOHXuG71VhfkUgRJMOZoC/V0RKudoTwWDzFgNXvYF3bqtpmQDW7 +2dUrSJ+jMOU7eCzXOdHDTFGhbQKBgQDfFQT/NACHapIn5w6c1Dha6fy7t1Z6A2zw +bUUzAh5C1kZ8yeDrkVfr5Ys+Y7Am/tfFteXO2XRSGH5yqq9YHVr0RihavqX72FGT +YY2rmyht+JjnZ3y+vOG5LXePR9tilvGei3jH0lTRPdwKpa6feHKry9MBx5xmqKqQ +xKRmyXaUUQKBgQCcOp3MqgEL1YGWhZhFKDp/+98B9mxnVgYiYojvu7Wt0jVuoZ+M +dZRowPrvyi7ccqwou+9tZNwiV1R2aTKqNmp44+k8xMT37GyXGdnmOWev77HY1b0H +w+lQEH4mpO9CELlllnTuZzGdBfj9gjJHQ9j9tlRqUDxTAGVxjzGOE1bgoQKBgQCu +DxmCAlIzVqzJY5hcN53tGcrvsKJRu2CBy9CFdy6jWctPzLipNROT5Nubh27HTmqP +QlkX50XCVIg88f60UttH44HTJBQgh+1GgIRolDycaa7sRyvnKzs4IEi8TAXaTAok +eZB44Rz60jhhOlsg5HscnoF6TwQyeYH0SOo5pRHXsQKBgQCY/pua7PceD5ZQ4lae +Pi5E9LzPjoeFegVgAP7bRUeC21nzLZlKYOcRCV2WkGLsz60bZm+7VEyFZmrrFoTE +58G0eCLCUq3Dj+NPfIvXNWwSuUAdDspWOBSCyENP+y+jLzIa2OtCj+KJe6Oe28pf +CcSeCJqr6aLeDRPcuD7yUat1OA== +-----END PRIVATE KEY-----` +) + +var ( + ErrMissingOrgID = errors.New("org_id is required") + ErrMissingRequestDigest = errors.New("request_digest is required") + ErrMissingIssuer = errors.New("issuer is required") + ErrMissingKeyID = errors.New("kid is required") + ErrMissingPrivateKey = errors.New("private key is required") +) + +type jwtWebKey struct { + Kid string `json:"kid"` + Alg string `json:"alg"` + Kty string `json:"kty"` + Use string `json:"use"` + N string `json:"n"` + E string `json:"e"` +} + +type jwtWebKeySet struct { + Keys []jwtWebKey `json:"keys"` +} + +// JWTTokenClaims describes the claims shape expected by Vault's JWT authorizer. +type JWTTokenClaims struct { + OrgID string + WorkflowOwner string + RequestDigest string + Issuer string + Audience string + Subject string + JWTID string + KeyID string + IssuedAt time.Time + ExpiresAt time.Time + ExtraClaims map[string]any +} + +// TestJWTIssuer is a minimal fake Auth0-style issuer for local CRE and system tests. +// It serves a JWKS endpoint and can mint RS256 JWTs matching Vault's expected claims. +type TestJWTIssuer struct { + server *http.Server + listener net.Listener + signers map[string]*rsa.PrivateKey + defaultKeyID string + mu sync.RWMutex +} + +// NewTestJWTIssuer creates a fake issuer with one generated RSA key and starts serving JWKS immediately. +func NewTestJWTIssuer() (*TestJWTIssuer, error) { + return NewTestJWTIssuerOnAddr("0.0.0.0:0") +} + +// NewTestJWTIssuerOnAddr creates a fake issuer bound to the provided TCP address. +func NewTestJWTIssuerOnAddr(listenAddr string) (*TestJWTIssuer, error) { + privateKey, err := parseDefaultJWTSigningKey() + if err != nil { + return nil, err + } + + return NewTestJWTIssuerWithKeysOnAddr(map[string]*rsa.PrivateKey{ + DefaultJWTIssuerKeyID: privateKey, + }, DefaultJWTIssuerKeyID, listenAddr) +} + +func parseDefaultJWTSigningKey() (*rsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(defaultJWTPrivateKeyPEM)) + if block == nil { + return nil, errors.New("failed to decode default JWT signing key PEM") + } + + privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse default JWT signing key: %w", err) + } + + rsaKey, ok := privateKey.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("default JWT signing key is not RSA") + } + + return rsaKey, nil +} + +// NewTestJWTIssuerWithKeys creates a fake issuer backed by the provided key set. +func NewTestJWTIssuerWithKeys(signers map[string]*rsa.PrivateKey, defaultKeyID string) (*TestJWTIssuer, error) { + return NewTestJWTIssuerWithKeysOnAddr(signers, defaultKeyID, "0.0.0.0:0") +} + +// NewTestJWTIssuerWithKeysOnAddr creates a fake issuer backed by the provided key set and listen address. +func NewTestJWTIssuerWithKeysOnAddr(signers map[string]*rsa.PrivateKey, defaultKeyID, listenAddr string) (*TestJWTIssuer, error) { + if len(signers) == 0 { + return nil, errors.New("at least one signer is required") + } + if _, ok := signers[defaultKeyID]; !ok { + return nil, fmt.Errorf("default signer %q is not present in key set", defaultKeyID) + } + if listenAddr == "" { + listenAddr = "0.0.0.0:0" + } + + // #nosec G102 -- test-only JWKS server must be reachable from Dockerized nodes during local CRE runs. + listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", listenAddr) + if err != nil { + return nil, fmt.Errorf("failed to allocate JWKS listener: %w", err) + } + + issuer := &TestJWTIssuer{ + listener: listener, + signers: signers, + defaultKeyID: defaultKeyID, + } + + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/jwks.json", issuer.handleJWKS) + mux.HandleFunc("/.well-known/openid-configuration", issuer.handleOpenIDConfiguration) + + issuer.server = &http.Server{ + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + + go func() { + _ = issuer.server.Serve(listener) + }() + + return issuer, nil +} + +// Close shuts down the fake issuer. +func (i *TestJWTIssuer) Close() error { + if i == nil || i.server == nil { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + return i.server.Shutdown(ctx) +} + +// LocalIssuerURL returns an issuer URL that the local test process can use. +func (i *TestJWTIssuer) LocalIssuerURL() string { + return i.baseURL("http://127.0.0.1") +} + +// DockerIssuerURL returns an issuer URL that Dockerized Chainlink nodes can use. +func (i *TestJWTIssuer) DockerIssuerURL() string { + return i.baseURL(framework.HostDockerInternal()) +} + +// MintToken signs a Vault-compatible JWT with one of the issuer's registered keys. +func (i *TestJWTIssuer) MintToken(claims JWTTokenClaims) (string, error) { + if claims.KeyID == "" { + claims.KeyID = i.defaultKeyID + } + if claims.Issuer == "" { + claims.Issuer = i.LocalIssuerURL() + } + if claims.Audience == "" { + claims.Audience = DefaultJWTAudience + } + + i.mu.RLock() + privateKey := i.signers[claims.KeyID] + i.mu.RUnlock() + if privateKey == nil { + return "", fmt.Errorf("signer %q not registered in issuer", claims.KeyID) + } + + return SignTestJWT(privateKey, claims) +} + +func (i *TestJWTIssuer) handleJWKS(w http.ResponseWriter, _ *http.Request) { + i.mu.RLock() + defer i.mu.RUnlock() + + keySet := jwtWebKeySet{ + Keys: make([]jwtWebKey, 0, len(i.signers)), + } + for keyID, privateKey := range i.signers { + keySet.Keys = append(keySet.Keys, rsaPublicKeyToJWK(keyID, &privateKey.PublicKey)) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(keySet) +} + +func (i *TestJWTIssuer) handleOpenIDConfiguration(w http.ResponseWriter, r *http.Request) { + issuerURL := requestBaseURL(r) + response := map[string]string{ + "issuer": issuerURL, + "jwks_uri": strings.TrimSuffix(issuerURL, "/") + "/.well-known/jwks.json", + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) +} + +func (i *TestJWTIssuer) baseURL(host string) string { + if i == nil || i.listener == nil { + return "" + } + + tcpAddr, ok := i.listener.Addr().(*net.TCPAddr) + if !ok { + return "" + } + + return withPort(host, tcpAddr.Port) +} + +// GenerateJWTSigningKey creates an RSA signing key suitable for RS256 JWTs. +func GenerateJWTSigningKey() (*rsa.PrivateKey, error) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, fmt.Errorf("failed to generate RSA signing key: %w", err) + } + return privateKey, nil +} + +// SignTestJWT signs a Vault-compatible RS256 JWT. +func SignTestJWT(privateKey *rsa.PrivateKey, claims JWTTokenClaims) (string, error) { + if privateKey == nil { + return "", ErrMissingPrivateKey + } + if claims.KeyID == "" { + return "", ErrMissingKeyID + } + if claims.Issuer == "" { + return "", ErrMissingIssuer + } + if claims.OrgID == "" { + return "", ErrMissingOrgID + } + if claims.RequestDigest == "" { + return "", ErrMissingRequestDigest + } + + now := time.Now().UTC() + if claims.IssuedAt.IsZero() { + claims.IssuedAt = now + } + if claims.ExpiresAt.IsZero() { + claims.ExpiresAt = claims.IssuedAt.Add(DefaultJWTLifetime) + } + if claims.Subject == "" { + claims.Subject = claims.OrgID + } + if claims.Audience == "" { + claims.Audience = DefaultJWTAudience + } + + tokenClaims := jwt.MapClaims{ + "iss": claims.Issuer, + "aud": claims.Audience, + "sub": claims.Subject, + "iat": jwt.NewNumericDate(claims.IssuedAt), + "exp": jwt.NewNumericDate(claims.ExpiresAt), + "org_id": claims.OrgID, + "authorization_details": []map[string]string{ + { + "type": "request_digest", + "value": claims.RequestDigest, + }, + }, + } + + if claims.WorkflowOwner != "" { + tokenClaims["authorization_details"] = []map[string]string{ + { + "type": "request_digest", + "value": claims.RequestDigest, + }, + { + "type": "workflow_owner", + "value": claims.WorkflowOwner, + }, + } + } + if claims.JWTID != "" { + tokenClaims["jti"] = claims.JWTID + } + for key, value := range claims.ExtraClaims { + tokenClaims[key] = value + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, tokenClaims) + token.Header["kid"] = claims.KeyID + + tokenString, err := token.SignedString(privateKey) + if err != nil { + return "", fmt.Errorf("failed to sign JWT: %w", err) + } + + return tokenString, nil +} + +// ComputeRequestDigest mirrors the digest computation used by Vault's authorizer. +func ComputeRequestDigest(req jsonrpc.Request[json.RawMessage]) (string, error) { + return req.Digest() +} + +// ComputeRawRequestDigest computes a Vault request digest from the marshalled JSON-RPC request body. +func ComputeRawRequestDigest(requestBody []byte) (string, error) { + var req jsonrpc.Request[json.RawMessage] + if err := json.Unmarshal(requestBody, &req); err != nil { + return "", fmt.Errorf("failed to decode JSON-RPC request: %w", err) + } + return ComputeRequestDigest(req) +} + +func rsaPublicKeyToJWK(keyID string, publicKey *rsa.PublicKey) jwtWebKey { + return jwtWebKey{ + Kid: keyID, + Alg: "RS256", + Kty: "RSA", + Use: "sig", + N: base64.RawURLEncoding.EncodeToString(publicKey.N.Bytes()), + E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(publicKey.E)).Bytes()), + } +} + +func requestBaseURL(r *http.Request) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + if forwardedProto := r.Header.Get("X-Forwarded-Proto"); forwardedProto != "" { + scheme = forwardedProto + } + + return withPort(scheme+"://"+r.Host, portFromRequest(r)) +} + +func portFromRequest(r *http.Request) int { + if r.URL.Port() != "" { + port, _ := strconv.Atoi(r.URL.Port()) + return port + } + if _, rawPort, err := net.SplitHostPort(r.Host); err == nil { + port, _ := strconv.Atoi(rawPort) + return port + } + return 0 +} + +func withPort(rawBase string, port int) string { + base, err := url.Parse(rawBase) + if err != nil { + return rawBase + } + + host := base.Hostname() + if host == "" { + host = strings.Trim(rawBase, "/") + } + + if port > 0 { + base.Host = net.JoinHostPort(host, strconv.Itoa(port)) + } else { + base.Host = host + } + base.Path = "/" + base.RawPath = "" + base.RawQuery = "" + base.Fragment = "" + + return base.String() +} diff --git a/system-tests/lib/cre/vault/jwt_auth_test.go b/system-tests/lib/cre/vault/jwt_auth_test.go new file mode 100644 index 00000000000..35ae0c825b2 --- /dev/null +++ b/system-tests/lib/cre/vault/jwt_auth_test.go @@ -0,0 +1,95 @@ +package vault + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + vaultcommon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault" + jsonrpc "github.com/smartcontractkit/chainlink-common/pkg/jsonrpc2" + "github.com/smartcontractkit/chainlink-common/pkg/settings/cresettings" + "github.com/smartcontractkit/chainlink-common/pkg/settings/limits" + linkingclient "github.com/smartcontractkit/chainlink-protos/linking-service/go/v1" + vaultcap "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault" + "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes" + "github.com/smartcontractkit/chainlink/v2/core/logger" +) + +func TestTestJWTIssuer_WorksWithVaultJWTBasedAuth(t *testing.T) { + issuer, err := NewTestJWTIssuer() + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, issuer.Close()) + }) + + params, err := json.Marshal(vaultcommon.ListSecretIdentifiersRequest{ + Namespace: "main", + }) + require.NoError(t, err) + + req := jsonrpc.Request[json.RawMessage]{ + Version: jsonrpc.JsonRpcVersion, + ID: "req-1", + Method: vaulttypes.MethodSecretsList, + Params: (*json.RawMessage)(¶ms), + } + + requestDigest, err := ComputeRequestDigest(req) + require.NoError(t, err) + + token, err := issuer.MintToken(JWTTokenClaims{ + KeyID: DefaultJWTIssuerKeyID, + Issuer: issuer.LocalIssuerURL(), + Audience: "https://api.test.chain.link", + OrgID: "org-test", + WorkflowOwner: "0xAbCdEf0123456789AbCdEf0123456789AbCdEf01", + RequestDigest: requestDigest, + }) + require.NoError(t, err) + + req.Auth = token + + auth, err := vaultcap.NewJWTBasedAuth(vaultcap.JWTBasedAuthConfig{ + IssuerURL: issuer.LocalIssuerURL(), + Audience: "https://api.test.chain.link", + }, limits.Factory{Settings: cresettings.DefaultGetter}, logger.TestLogger(t), vaultcap.WithJWTBasedAuthGateLimiter(limits.NewGateLimiter(true))) + require.NoError(t, err) + + authResult, err := auth.AuthorizeRequest(t.Context(), req) + require.NoError(t, err) + require.Equal(t, "org-test", authResult.OrgID()) + require.Equal(t, "0xAbCdEf0123456789AbCdEf0123456789AbCdEf01", authResult.WorkflowOwner()) + require.Equal(t, requestDigest, authResult.Digest()) +} + +func TestTestLinkingService_ResolvesOwner(t *testing.T) { + svc, err := NewTestLinkingService(map[string]string{ + "0xAbC": "org-123", + "d6d4fc38c209f53caa5a311a0cb44259daa4e9e1": "org-456", + }) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, svc.Close()) + }) + + conn, err := grpc.NewClient(svc.LocalURL(), grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, conn.Close()) + }) + + resp, err := linkingclient.NewLinkingServiceClient(conn).GetOrganizationFromWorkflowOwner(t.Context(), &linkingclient.GetOrganizationFromWorkflowOwnerRequest{ + WorkflowOwner: "0xabc", + }) + require.NoError(t, err) + require.Equal(t, "org-123", resp.GetOrganizationId()) + + resp, err = linkingclient.NewLinkingServiceClient(conn).GetOrganizationFromWorkflowOwner(t.Context(), &linkingclient.GetOrganizationFromWorkflowOwnerRequest{ + WorkflowOwner: "0xD6d4fC38c209F53caa5a311a0cb44259dAA4E9e1", + }) + require.NoError(t, err) + require.Equal(t, "org-456", resp.GetOrganizationId()) +} diff --git a/system-tests/lib/cre/vault/linking_service.go b/system-tests/lib/cre/vault/linking_service.go new file mode 100644 index 00000000000..f1b3460df3a --- /dev/null +++ b/system-tests/lib/cre/vault/linking_service.go @@ -0,0 +1,150 @@ +package vault + +import ( + "context" + "errors" + "fmt" + "net" + "strings" + "sync" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + linkingclient "github.com/smartcontractkit/chainlink-protos/linking-service/go/v1" + "github.com/smartcontractkit/chainlink-testing-framework/framework" +) + +// TestLinkingService is a local gRPC fixture that resolves workflow owners to org IDs. +type TestLinkingService struct { + linkingclient.UnimplementedLinkingServiceServer + + server *grpc.Server + listener net.Listener + + mu sync.RWMutex + ownerToOrg map[string]string +} + +const SharedTestLinkingServiceAddr = "0.0.0.0:18124" + +var ( + sharedTestLinkingServiceOnce sync.Once + sharedTestLinkingService *TestLinkingService + errSharedTestLinkingService error +) + +// NewTestLinkingService starts a mock linking service immediately. +func NewTestLinkingService(ownerToOrg map[string]string) (*TestLinkingService, error) { + return NewTestLinkingServiceOnAddr(ownerToOrg, "0.0.0.0:0") +} + +// EnsureSharedTestLinkingServiceStarted starts the shared local linking service once +// on the fixed host port that Dockerized nodes use during local CRE runs. +func EnsureSharedTestLinkingServiceStarted() (*TestLinkingService, error) { + sharedTestLinkingServiceOnce.Do(func() { + sharedTestLinkingService, errSharedTestLinkingService = NewTestLinkingServiceOnAddr(nil, SharedTestLinkingServiceAddr) + }) + + return sharedTestLinkingService, errSharedTestLinkingService +} + +// NewTestLinkingServiceOnAddr starts a mock linking service on the provided TCP address. +func NewTestLinkingServiceOnAddr(ownerToOrg map[string]string, listenAddr string) (*TestLinkingService, error) { + if listenAddr == "" { + listenAddr = "0.0.0.0:0" + } + + // #nosec G102 -- test-only linking server must be reachable from Dockerized nodes during local CRE runs. + listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", listenAddr) + if err != nil { + return nil, fmt.Errorf("failed to allocate linking service listener: %w", err) + } + + svc := &TestLinkingService{ + listener: listener, + ownerToOrg: make(map[string]string, len(ownerToOrg)), + } + for owner, orgID := range ownerToOrg { + svc.ownerToOrg[normalizeWorkflowOwner(owner)] = orgID + } + + server := grpc.NewServer() + linkingclient.RegisterLinkingServiceServer(server, svc) + svc.server = server + + go func() { + _ = server.Serve(listener) + }() + + return svc, nil +} + +// Close stops the mock gRPC service. +func (s *TestLinkingService) Close() error { + if s == nil || s.server == nil { + return nil + } + + s.server.Stop() + if s.listener != nil { + if err := s.listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) { + return err + } + } + + return nil +} + +// LocalURL returns the host-local gRPC address. +func (s *TestLinkingService) LocalURL() string { + return s.baseURL("127.0.0.1") +} + +// DockerURL returns the address Dockerized nodes can use. +func (s *TestLinkingService) DockerURL() string { + return s.baseURL(strings.TrimPrefix(framework.HostDockerInternal(), "http://")) +} + +// SetOwnerOrg installs or updates a workflow-owner mapping. +func (s *TestLinkingService) SetOwnerOrg(owner, orgID string) { + s.mu.Lock() + defer s.mu.Unlock() + + s.ownerToOrg[normalizeWorkflowOwner(owner)] = orgID +} + +// GetOrganizationFromWorkflowOwner implements the linking-service gRPC API. +func (s *TestLinkingService) GetOrganizationFromWorkflowOwner(_ context.Context, req *linkingclient.GetOrganizationFromWorkflowOwnerRequest) (*linkingclient.GetOrganizationFromWorkflowOwnerResponse, error) { + owner := normalizeWorkflowOwner(req.GetWorkflowOwner()) + + s.mu.RLock() + orgID, ok := s.ownerToOrg[owner] + s.mu.RUnlock() + if !ok { + return nil, status.Errorf(codes.NotFound, "workflow owner %q not linked", req.GetWorkflowOwner()) + } + + return &linkingclient.GetOrganizationFromWorkflowOwnerResponse{ + OrganizationId: orgID, + }, nil +} + +func (s *TestLinkingService) baseURL(host string) string { + if s == nil || s.listener == nil { + return "" + } + + tcpAddr, ok := s.listener.Addr().(*net.TCPAddr) + if !ok { + return "" + } + + return fmt.Sprintf("%s:%d", host, tcpAddr.Port) +} + +func normalizeWorkflowOwner(owner string) string { + owner = strings.ToLower(strings.TrimSpace(owner)) + return strings.TrimPrefix(owner, "0x") +} diff --git a/system-tests/lib/cre/workflow/workflow.go b/system-tests/lib/cre/workflow/workflow.go index 2e7df738450..7d3d9760dde 100644 --- a/system-tests/lib/cre/workflow/workflow.go +++ b/system-tests/lib/cre/workflow/workflow.go @@ -149,11 +149,7 @@ func LinkOwner(sc *seth.Client, workflowRegistryAddr common.Address, version *se signature[64] += 27 _, err = sc.Decode(registry.LinkOwner(sc.NewTXOpts(), validityTimestamp, common.HexToHash(ownershipProof), signature)) - if err != nil { - return err - } - - return nil + return err default: return errors.New("invalid version for linking owner") } diff --git a/system-tests/lib/go.mod b/system-tests/lib/go.mod index 1481772c9ce..22bdf66adad 100644 --- a/system-tests/lib/go.mod +++ b/system-tests/lib/go.mod @@ -21,6 +21,7 @@ require ( github.com/gagliardetto/solana-go v1.13.0 github.com/go-resty/resty/v2 v2.17.2 github.com/goccy/go-yaml v1.19.2 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/jmoiron/sqlx v1.4.0 github.com/pelletier/go-toml/v2 v2.3.0 @@ -38,6 +39,7 @@ require ( github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260119171452-39c98c3b33cd github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877 github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 + github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b github.com/smartcontractkit/chainlink-protos/workflows/go v0.0.0-20260323124644-faea187e6997 github.com/smartcontractkit/chainlink-solana v1.1.2-0.20260421131224-c46cbfe7bc6c github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.17 @@ -271,7 +273,6 @@ require ( github.com/gofrs/flock v0.13.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/btree v1.1.3 // indirect @@ -471,7 +472,6 @@ require ( github.com/smartcontractkit/chainlink-protos/chainlink-ccv/heartbeat v0.0.0-20260115142640-f6b99095c12e // indirect github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-discovery v0.0.0-20251211142334-5c3421fe2c8d // indirect github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect - github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260319180422-b5808c964785 // indirect github.com/smartcontractkit/chainlink-protos/orchestrator v0.10.0 // indirect github.com/smartcontractkit/chainlink-protos/ring/go v0.0.0-20260331131315-f08a616d8dcd // indirect diff --git a/system-tests/tests/go.mod b/system-tests/tests/go.mod index e084a8b803e..53655321f4f 100644 --- a/system-tests/tests/go.mod +++ b/system-tests/tests/go.mod @@ -385,7 +385,7 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/status v1.1.1 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/btree v1.1.3 // indirect @@ -630,7 +630,7 @@ require ( github.com/smartcontractkit/mcms v0.41.1 // indirect github.com/smartcontractkit/smdkg v0.0.0-20251029093710-c38905e58aeb // indirect github.com/smartcontractkit/tdh2/go/ocr2/decryptionplugin v0.0.0-20241009055228-33d0c0bf38de // indirect - github.com/smartcontractkit/tdh2/go/tdh2 v0.0.0-20251120172354-e8ec0386b06c // indirect + github.com/smartcontractkit/tdh2/go/tdh2 v0.0.0-20251120172354-e8ec0386b06c github.com/smartcontractkit/wsrpc v0.8.5-0.20250502134807-c57d3d995945 // indirect github.com/sony/gobreaker/v2 v2.4.0 // indirect github.com/spf13/cast v1.10.0 // indirect diff --git a/system-tests/tests/smoke/cre/cre_suite_test.go b/system-tests/tests/smoke/cre/cre_suite_test.go index e039bfbe083..9a4c28fe64e 100644 --- a/system-tests/tests/smoke/cre/cre_suite_test.go +++ b/system-tests/tests/smoke/cre/cre_suite_test.go @@ -144,8 +144,43 @@ func runV2SuiteScenario(t *testing.T, topology string, scenario v2suite_config.S if parallelEnabled { t.Parallel() } - testEnv := t_helpers.SetupTestEnvironmentWithPerTestKeys(t, t_helpers.GetDefaultTestConfig(t)) - ExecuteVaultTest(t, testEnv) + allowlistSubtestName := "allowlist_auth_when_jwt_auth_disabled" + jwtSubtestName := "jwt_auth_rejected_when_jwt_auth_disabled" + vaultConfig := getVaultDefaultTestConfig(t) + if isVaultJWTAuthEnabledTopology(topology) { + vaultConfig = getVaultJWTAuthEnabledTestConfig(t) + allowlistSubtestName = "allowlist_auth_when_jwt_auth_enabled" + jwtSubtestName = "jwt_auth_when_jwt_auth_enabled" + } + fixture := setupVaultSharedScenarioFixture(t, vaultConfig) + allowlistEnv := fixture.TestEnv + jwtEnv := fixture.TestEnv + if parallelEnabled && isVaultJWTAuthEnabledTopology(topology) { + allowlistEnv = t_helpers.SetupTestEnvironmentWithPerTestKeys(t, fixture.TestEnv.TestConfig) + jwtEnv = t_helpers.SetupTestEnvironmentWithPerTestKeys(t, fixture.TestEnv.TestConfig) + } + + t.Run(allowlistSubtestName, func(t *testing.T) { + if parallelEnabled { + t.Parallel() + } + ExecuteVaultAllowListBasedTests(t, fixture, allowlistEnv) + }) + if isVaultJWTAuthEnabledTopology(topology) { + t.Run(jwtSubtestName, func(t *testing.T) { + if parallelEnabled { + t.Parallel() + } + ExecuteVaultMixedAuthTest(t, fixture, jwtEnv) + }) + return + } + t.Run(jwtSubtestName, func(t *testing.T) { + if parallelEnabled { + t.Parallel() + } + ExecuteVaultJWTDisabledTest(t, fixture) + }) }) case v2suite_config.SuiteScenarioCronBeholder: // NOTE: this test is not easily parallelisable, because it uses "real" ChIP Ingress stack diff --git a/system-tests/tests/smoke/cre/v2_vault_don_test.go b/system-tests/tests/smoke/cre/v2_vault_don_test.go index d4277dce631..8a29f663903 100644 --- a/system-tests/tests/smoke/cre/v2_vault_don_test.go +++ b/system-tests/tests/smoke/cre/v2_vault_don_test.go @@ -2,96 +2,68 @@ package cre import ( "context" - "encoding/hex" "encoding/json" - "math/big" "math/rand" - "net/http" - "net/url" - "slices" "strconv" + "strings" "testing" "time" - "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/stretchr/testify/require" - "google.golang.org/protobuf/encoding/protojson" - "google.golang.org/protobuf/proto" vault_helpers "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault" - capabilitiespb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/pb" jsonrpc "github.com/smartcontractkit/chainlink-common/pkg/jsonrpc2" - capabilities_registry_v2 "github.com/smartcontractkit/chainlink-evm/gethwrappers/workflow/generated/capabilities_registry_wrapper_v2" - "github.com/smartcontractkit/chainlink-protos/cre/go/values" commonevents "github.com/smartcontractkit/chainlink-protos/workflows/go/common" workflowevents "github.com/smartcontractkit/chainlink-protos/workflows/go/events" - ctfblockchain "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" - "github.com/smartcontractkit/chainlink-testing-framework/seth" keystone_changeset "github.com/smartcontractkit/chainlink/deployment/keystone/changeset" crecontracts "github.com/smartcontractkit/chainlink/system-tests/lib/cre/contracts" "github.com/smartcontractkit/chainlink/system-tests/lib/cre/environment/blockchains/evm" - vaultsecret_config "github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/vaultsecret/config" t_helpers "github.com/smartcontractkit/chainlink/system-tests/tests/test-helpers" "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes" + "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaultutils" workflow_registry_v2_wrapper "github.com/smartcontractkit/chainlink-evm/gethwrappers/workflow/generated/workflow_registry_wrapper_v2" - "github.com/smartcontractkit/chainlink/system-tests/lib/cre" + envconfig "github.com/smartcontractkit/chainlink/system-tests/lib/cre/environment/config" crevault "github.com/smartcontractkit/chainlink/system-tests/lib/cre/features/vault" "github.com/smartcontractkit/chainlink/system-tests/lib/cre/vault" - creworkflow "github.com/smartcontractkit/chainlink/system-tests/lib/cre/workflow" ttypes "github.com/smartcontractkit/chainlink/system-tests/tests/test-helpers/configuration" "github.com/smartcontractkit/chainlink-testing-framework/framework" ) -func ExecuteVaultTest(t *testing.T, testEnv *ttypes.TestEnvironment) { +func ExecuteVaultAllowListBasedTests(t *testing.T, fixture *vaultScenarioFixture, testEnv *ttypes.TestEnvironment) { var testLogger = framework.L + linkingService := fixture.LinkingService - testLogger.Info().Msgf("Ensuring DKG result packages are present...") - require.Eventually(t, func() bool { - for _, nodeSet := range testEnv.Config.NodeSets { - if slices.Contains(nodeSet.Capabilities, cre.VaultCapability) { - for i, node := range nodeSet.NodeSpecs { - if !slices.Contains(node.Roles, cre.BootstrapNode) { - packageCount, err := vault.GetResultPackageCount(t.Context(), i, nodeSet.DbInput.Port) - if err != nil || packageCount != 1 { - return false - } - } - } - return true - } - } - return false - }, time.Second*300, time.Second*5) - - testLogger.Info().Msg("Getting gateway configuration...") - require.NotEmpty(t, testEnv.Dons.GatewayConnectors.Configurations, "expected at least one gateway configuration") - gatewayURL, err := url.Parse(testEnv.Dons.GatewayConnectors.Configurations[0].Incoming.Protocol + "://" + testEnv.Dons.GatewayConnectors.Configurations[0].Incoming.Host + ":" + strconv.Itoa(testEnv.Dons.GatewayConnectors.Configurations[0].Incoming.ExternalPort) + testEnv.Dons.GatewayConnectors.Configurations[0].Incoming.Path) - require.NoError(t, err, "failed to parse gateway URL") - testLogger.Info().Msgf("Gateway URL: %s", gatewayURL.String()) - - vaultPublicKey := FetchVaultPublicKey(t, gatewayURL.String()) - updateVaultCapabilityConfigInRegistry(t, testEnv, vaultPublicKey) - - gwURL := gatewayURL.String() + gwURL := fixture.GatewayURL.String() + vaultPublicKey := fixture.VaultPublicKey - t.Run("basic_crud", func(t *testing.T) { - if parallelEnabled { - t.Parallel() - } - subEnv := t_helpers.SetupTestEnvironmentWithPerTestKeys(t, testEnv.TestConfig) - sc := subEnv.CreEnvironment.Blockchains[0].(*evm.Blockchain).SethClient + t.Run("allowlist_crud_with_workflow_owner_identity", func(t *testing.T) { + sc := testEnv.CreEnvironment.Blockchains[0].(*evm.Blockchain).SethClient owner := sc.MustGetRootKeyAddress().Hex() - wfRegAddr := crecontracts.MustGetAddressFromDataStore(subEnv.CreEnvironment.CldfEnvironment.DataStore, subEnv.CreEnvironment.Blockchains[0].ChainSelector(), keystone_changeset.WorkflowRegistry.String(), subEnv.CreEnvironment.ContractVersions[keystone_changeset.WorkflowRegistry.String()], "") + expectedResponseOwner := owner + orgIDAsSecretOwnerEnabled := isVaultJWTAuthEnabledTopology(testEnv.TestConfig.EnvironmentConfigPath) + if linkingService != nil { + orgID := "org" + strings.ReplaceAll(uuid.NewString(), "-", "") + linkingService.SetOwnerOrg(owner, orgID) + if orgIDAsSecretOwnerEnabled { + expectedResponseOwner = orgID + } + } + wfRegAddr := crecontracts.MustGetAddressFromDataStore(testEnv.CreEnvironment.CldfEnvironment.DataStore, testEnv.CreEnvironment.Blockchains[0].ChainSelector(), keystone_changeset.WorkflowRegistry.String(), testEnv.CreEnvironment.ContractVersions[keystone_changeset.WorkflowRegistry.String()], "") wfReg, err := workflow_registry_v2_wrapper.NewWorkflowRegistry(common.HexToAddress(wfRegAddr), sc.Client) require.NoError(t, err) - require.NoError(t, creworkflow.LinkOwner(sc, common.HexToAddress(wfRegAddr), subEnv.CreEnvironment.ContractVersions[keystone_changeset.WorkflowRegistry.String()])) + requireVaultLinkOwner(t, sc, common.HexToAddress(wfRegAddr), testEnv.CreEnvironment.ContractVersions[keystone_changeset.WorkflowRegistry.String()]) secretID := strconv.Itoa(rand.Intn(10000)) - enc, err := crevault.EncryptSecret("secret-basic", vaultPublicKey, sc.MustGetRootKeyAddress()) + createValue := "secret-basic-create" + updateValue := "secret-basic-update" + createEnc, err := crevault.EncryptSecret(createValue, vaultPublicKey, sc.MustGetRootKeyAddress()) + require.NoError(t, err) + updateEnc, err := crevault.EncryptSecret(updateValue, vaultPublicKey, sc.MustGetRootKeyAddress()) require.NoError(t, err) ulCh := make(chan *workflowevents.UserLogs, 1000) bmCh := make(chan *commonevents.BaseMessage, 1000) @@ -104,518 +76,283 @@ func ExecuteVaultTest(t *testing.T, testEnv *ttypes.TestEnvironment) { }) namespaces := []string{"main", "alt"} - executeVaultSecretsCreateTest(t, enc, secretID, owner, gwURL, namespaces, sc, wfReg) - executeVaultSecretsGetViaWorkflowTest(t, subEnv, "bget1", secretID, "main", ulCh, bmCh) - executeVaultSecretsGetViaWorkflowTest(t, subEnv, "bgeta1", secretID, "alt", ulCh, bmCh) - executeVaultSecretsUpdateTest(t, enc, secretID, owner, gwURL, namespaces, sc, wfReg) - executeVaultSecretsGetViaWorkflowTest(t, subEnv, "bget2", secretID, "main", ulCh, bmCh) - executeVaultSecretsGetViaWorkflowTest(t, subEnv, "bgeta2", secretID, "alt", ulCh, bmCh) - executeVaultSecretsListTest(t, secretID, owner, gwURL, "main", sc, wfReg) - executeVaultSecretsListTest(t, secretID, owner, gwURL, "alt", sc, wfReg) - executeVaultSecretsDeleteTest(t, secretID, owner, gwURL, []string{"main"}, sc, wfReg) - executeVaultSecretsGetNotFoundViaWorkflowTest(t, subEnv, "bdel1", secretID, "main", ulCh, bmCh) - executeVaultSecretsGetViaWorkflowTest(t, subEnv, "bgeta3", secretID, "alt", ulCh, bmCh) - executeVaultSecretsDeleteTest(t, secretID, owner, gwURL, []string{"alt"}, sc, wfReg) - executeVaultSecretsGetNotFoundViaWorkflowTest(t, subEnv, "bdela1", secretID, "alt", ulCh, bmCh) + executeVaultAllowListSecretsCreateTest(t, createEnc, secretID, owner, expectedResponseOwner, gwURL, namespaces, sc, wfReg) + executeVaultSecretsUpdateTest(t, updateEnc, secretID, owner, expectedResponseOwner, gwURL, namespaces, sc, wfReg) + executeVaultSecretsListTest(t, secretID, owner, expectedResponseOwner, gwURL, "main", sc, wfReg) + executeVaultSecretsListTest(t, secretID, owner, expectedResponseOwner, gwURL, "alt", sc, wfReg) + executeVaultSecretsDeleteTest(t, secretID, owner, expectedResponseOwner, gwURL, []string{"main"}, sc, wfReg) + executeVaultSecretsWorkflowChecksTest(t, testEnv, "allowlist-final-verify", []vaultWorkflowCheck{ + {Name: "allowlist-main-not-found", SecretKey: secretID, SecretNamespace: "main", ExpectNotFound: true}, + {Name: "allowlist-alt-updated", SecretKey: secretID, SecretNamespace: "alt", ExpectedValue: updateValue}, + }, ulCh, bmCh) + executeVaultSecretsDeleteTest(t, secretID, owner, expectedResponseOwner, gwURL, []string{"alt"}, sc, wfReg) }) } -func executeVaultSecretsCreateTest(t *testing.T, encryptedSecret, secretID, owner, gatewayURL string, namespaces []string, sethClient *seth.Client, wfRegistryContract *workflow_registry_v2_wrapper.WorkflowRegistry) { - framework.L.Info().Msgf("Creating secrets (namespaces=%v)...", namespaces) - - uniqueRequestID := uuid.New().String() - - encryptedSecrets := make([]*vault_helpers.EncryptedSecret, 0, len(namespaces)) - for _, namespace := range namespaces { - encryptedSecrets = append(encryptedSecrets, &vault_helpers.EncryptedSecret{ - Id: &vault_helpers.SecretIdentifier{ - Key: secretID, - Owner: owner, - Namespace: namespace, - }, - EncryptedValue: encryptedSecret, - }) - } - - secretsCreateRequest := vault_helpers.CreateSecretsRequest{ - RequestId: uniqueRequestID, - EncryptedSecrets: encryptedSecrets, - } - secretsCreateRequestBody, err := json.Marshal(secretsCreateRequest) //nolint:govet // The lock field is not set on this proto - require.NoError(t, err, "failed to marshal secrets request") - secretsCreateRequestBodyJSON := json.RawMessage(secretsCreateRequestBody) - jsonRequest := jsonrpc.Request[json.RawMessage]{ - Version: jsonrpc.JsonRpcVersion, - ID: uniqueRequestID, - Method: vaulttypes.MethodSecretsCreate, - Params: &secretsCreateRequestBodyJSON, - } - allowlistRequest(t, owner, jsonRequest, sethClient, wfRegistryContract) - - requestBody, err := json.Marshal(jsonRequest) - require.NoError(t, err, "failed to marshal secrets request") - - statusCode, httpResponseBody := sendVaultRequestToGateway(t, gatewayURL, requestBody) - require.Equal(t, http.StatusOK, statusCode, "Gateway endpoint should respond with 200 OK") - - framework.L.Info().Msg("Checking jsonResponse structure...") - var jsonResponse jsonrpc.Response[vaulttypes.SignedOCRResponse] - err = json.Unmarshal(httpResponseBody, &jsonResponse) - require.NoError(t, err, "failed to unmarshal getResponse") - framework.L.Info().Msgf("JSON Body: %v", jsonResponse) - if jsonResponse.Error != nil { - require.Empty(t, jsonResponse.Error.Error()) - } - require.Equal(t, jsonrpc.JsonRpcVersion, jsonResponse.Version) - require.Equal(t, uniqueRequestID, jsonResponse.ID) - require.Equal(t, vaulttypes.MethodSecretsCreate, jsonResponse.Method) - - signedOCRResponse := jsonResponse.Result - framework.L.Info().Msgf("Signed OCR Response: %s", signedOCRResponse.String()) - - // TODO: Verify the authenticity of this signed report, by ensuring that the signatures indeed match the payload - createSecretsResponse := vault_helpers.CreateSecretsResponse{} - err = protojson.Unmarshal(signedOCRResponse.Payload, &createSecretsResponse) - require.NoError(t, err, "failed to decode payload into CreateSecretsResponse proto") - framework.L.Info().Msgf("CreateSecretsResponse decoded as: %s", createSecretsResponse.String()) - - require.Len(t, createSecretsResponse.Responses, len(namespaces), "Expected one item in the response per namespace") - respByNs := make(map[string]*vault_helpers.CreateSecretResponse, len(namespaces)) - for _, r := range createSecretsResponse.GetResponses() { - respByNs[r.GetId().GetNamespace()] = r - } - for _, namespace := range namespaces { - result, ok := respByNs[namespace] - require.True(t, ok, "missing response for namespace %s", namespace) - require.Empty(t, result.GetError()) - require.Equal(t, secretID, result.GetId().Key) - require.Equal(t, owner, result.GetId().Owner) - } - - framework.L.Info().Msgf("Secrets created successfully (namespaces=%v)", namespaces) -} - -func executeVaultSecretsGetViaWorkflowTest( - t *testing.T, testEnv *ttypes.TestEnvironment, - workflowBaseName, secretKey, secretNamespace string, - userLogsCh chan *workflowevents.UserLogs, baseMessageCh chan *commonevents.BaseMessage, -) { +func ExecuteVaultMixedAuthTest(t *testing.T, fixture *vaultScenarioFixture, testEnv *ttypes.TestEnvironment) { testLogger := framework.L - testLogger.Info().Msgf("Verifying secret retrieval via workflow (key=%s, namespace=%s)...", secretKey, secretNamespace) + issuer := fixture.Issuer + linkingService := fixture.LinkingService - workflowName := t_helpers.UniqueWorkflowName(testEnv, workflowBaseName) - cfg := &vaultsecret_config.Config{ - SecretKey: secretKey, - SecretNamespace: secretNamespace, - } - const workflowFileLocation = "./vaultsecret/main.go" - workflowID := t_helpers.CompileAndDeployWorkflow(t, testEnv, testLogger, workflowName, cfg, workflowFileLocation) + gatewayURL := fixture.GatewayURL + vaultPublicKey := fixture.VaultPublicKey - expectedLog := "Vault secret retrieved successfully via workflow" - t_helpers.WatchWorkflowLogs(t, testLogger, userLogsCh, baseMessageCh, t_helpers.WorkflowEngineInitErrorLog, expectedLog, 4*time.Minute, t_helpers.WithUserLogWorkflowID(workflowID)) - testLogger.Info().Msg("Vault secret get via workflow test completed") -} + sc := testEnv.CreEnvironment.Blockchains[0].(*evm.Blockchain).SethClient + workflowOwner := sc.MustGetRootKeyAddress().Hex() + orgID := "org" + strings.ReplaceAll(uuid.NewString(), "-", "") + linkingService.SetOwnerOrg(workflowOwner, orgID) -func executeVaultSecretsGetNotFoundViaWorkflowTest( - t *testing.T, testEnv *ttypes.TestEnvironment, - workflowBaseName, secretKey, secretNamespace string, - userLogsCh chan *workflowevents.UserLogs, baseMessageCh chan *commonevents.BaseMessage, -) { - testLogger := framework.L - testLogger.Info().Msgf("Verifying secret is NOT retrievable via workflow after deletion (key=%s, namespace=%s)...", secretKey, secretNamespace) + wfRegAddr := crecontracts.MustGetAddressFromDataStore( + testEnv.CreEnvironment.CldfEnvironment.DataStore, + testEnv.CreEnvironment.Blockchains[0].ChainSelector(), + keystone_changeset.WorkflowRegistry.String(), + testEnv.CreEnvironment.ContractVersions[keystone_changeset.WorkflowRegistry.String()], + "", + ) + wfReg, err := workflow_registry_v2_wrapper.NewWorkflowRegistry(common.HexToAddress(wfRegAddr), sc.Client) + require.NoError(t, err) + requireVaultLinkOwner(t, sc, common.HexToAddress(wfRegAddr), testEnv.CreEnvironment.ContractVersions[keystone_changeset.WorkflowRegistry.String()]) + + allowlistAuth := newAllowlistVaultRequestAuth(workflowOwner, sc, wfReg) + + ulCh := make(chan *workflowevents.UserLogs, 1000) + bmCh := make(chan *commonevents.BaseMessage, 1000) + sink := t_helpers.StartChipTestSink(t, t_helpers.GetPublishFn(testLogger, ulCh, bmCh)) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + t_helpers.ShutdownChipSinkWithDrain(ctx, sink, ulCh, bmCh) + }) - workflowName := t_helpers.UniqueWorkflowName(testEnv, workflowBaseName) - cfg := &vaultsecret_config.Config{ - SecretKey: secretKey, - SecretNamespace: secretNamespace, - ExpectNotFound: true, - } - const workflowFileLocation = "./vaultsecret/main.go" - workflowID := t_helpers.CompileAndDeployWorkflow(t, testEnv, testLogger, workflowName, cfg, workflowFileLocation) + gwURL := gatewayURL.String() + jwtAuth := newJWTVaultRequestAuth(issuer, orgID, workflowOwner) + vaultParsedPublicKey := mustVaultPublicKey(t, vaultPublicKey) + workflowOwnerAddress := common.HexToAddress(workflowOwner) - expectedLog := "Vault secret correctly not found after deletion" - t_helpers.WatchWorkflowLogs(t, testLogger, userLogsCh, baseMessageCh, t_helpers.WorkflowEngineInitErrorLog, expectedLog, 4*time.Minute, t_helpers.WithUserLogWorkflowID(workflowID)) - testLogger.Info().Msg("Vault secret not-found via workflow test completed") -} + t.Run("jwt_crud_with_workflow_owner", func(t *testing.T) { + secretID := strconv.Itoa(rand.Intn(10000)) + createValue := "secret-jwt-workflow-owner" + enc, err := vaultutils.EncryptSecretWithOrgID(createValue, vaultParsedPublicKey, orgID) + require.NoError(t, err) -func executeVaultSecretsUpdateTest(t *testing.T, encryptedSecret, secretID, owner, gatewayURL string, namespaces []string, sethClient *seth.Client, wfRegistryContract *workflow_registry_v2_wrapper.WorkflowRegistry) { - framework.L.Info().Msgf("Updating secrets (namespaces=%v)...", namespaces) - uniqueRequestID := uuid.New().String() - - encryptedSecrets := make([]*vault_helpers.EncryptedSecret, 0, len(namespaces)+1) - for _, namespace := range namespaces { - encryptedSecrets = append(encryptedSecrets, &vault_helpers.EncryptedSecret{ - Id: &vault_helpers.SecretIdentifier{ - Key: secretID, - Owner: owner, - Namespace: namespace, + executeVaultJWTSecretsCreateTest(t, issuer, enc, secretID, orgID, workflowOwner, gwURL, []string{"main", "alt"}) + workflowID := startVaultSecretsWorkflowPhasesTest(t, testEnv, "jwt-lifecycle", []vaultWorkflowPhase{ + { + Name: "jwt-created", + Checks: []vaultWorkflowCheck{ + {Name: "jwt-create-get-main", SecretKey: secretID, SecretNamespace: "main", ExpectedValue: createValue}, + {Name: "jwt-create-get-alt", SecretKey: secretID, SecretNamespace: "alt", ExpectedValue: createValue}, + }, + }, + { + Name: "jwt-deleted", + Checks: []vaultWorkflowCheck{ + {Name: "jwt-delete-main-not-found", SecretKey: secretID, SecretNamespace: "main", ExpectNotFound: true}, + {Name: "jwt-delete-alt-not-found", SecretKey: secretID, SecretNamespace: "alt", ExpectNotFound: true}, + }, }, - EncryptedValue: encryptedSecret, }) - } - encryptedSecrets = append(encryptedSecrets, &vault_helpers.EncryptedSecret{ - Id: &vault_helpers.SecretIdentifier{ - Key: "invalid", - Owner: owner, - Namespace: namespaces[0], - }, - EncryptedValue: encryptedSecret, + waitForVaultWorkflowPhase(t, workflowID, "jwt-created", ulCh, bmCh) + executeVaultJWTSecretsListTest(t, issuer, secretID, orgID, workflowOwner, gwURL, "main") + executeVaultJWTSecretsListTest(t, issuer, secretID, orgID, workflowOwner, gwURL, "alt") + executeVaultJWTSecretsDeleteTest(t, issuer, secretID, orgID, workflowOwner, gwURL, []string{"main", "alt"}) + waitForVaultWorkflowPhase(t, workflowID, "jwt-deleted", ulCh, bmCh) }) - secretsUpdateRequest := vault_helpers.UpdateSecretsRequest{ - RequestId: uniqueRequestID, - EncryptedSecrets: encryptedSecrets, - } - secretsUpdateRequestBody, err := json.Marshal(secretsUpdateRequest) //nolint:govet // The lock field is not set on this proto - require.NoError(t, err, "failed to marshal secrets request") - secretsUpdateRequestBodyJSON := json.RawMessage(secretsUpdateRequestBody) - jsonRequest := jsonrpc.Request[json.RawMessage]{ - Version: jsonrpc.JsonRpcVersion, - ID: uniqueRequestID, - Method: vaulttypes.MethodSecretsUpdate, - Params: &secretsUpdateRequestBodyJSON, - } - allowlistRequest(t, owner, jsonRequest, sethClient, wfRegistryContract) - - requestBody, err := json.Marshal(jsonRequest) - require.NoError(t, err, "failed to marshal secrets request") - - statusCode, httpResponseBody := sendVaultRequestToGateway(t, gatewayURL, requestBody) - require.Equal(t, http.StatusOK, statusCode, "Gateway endpoint should respond with 200 OK") + t.Run("mixed_allowlist_and_jwt_auth", func(t *testing.T) { + t.Run("cross_auth_create_update_list_and_delete", func(t *testing.T) { + allowlistSecretID := strconv.Itoa(rand.Intn(10000)) + jwtSecretID := strconv.Itoa(rand.Intn(10000)) + allowlistCreateValue := "secret-mixed-allowlist-create" + jwtCreateValue := "secret-mixed-jwt-create" + allowlistUpdateValue := "secret-mixed-allowlist-update" + jwtUpdateValue := "secret-mixed-jwt-update" + allowlistCreateEnc, err := crevault.EncryptSecret(allowlistCreateValue, vaultPublicKey, workflowOwnerAddress) + require.NoError(t, err) + jwtCreateEnc, err := vaultutils.EncryptSecretWithOrgID(jwtCreateValue, vaultParsedPublicKey, orgID) + require.NoError(t, err) + allowlistUpdateEnc, err := crevault.EncryptSecret(allowlistUpdateValue, vaultPublicKey, workflowOwnerAddress) + require.NoError(t, err) + jwtUpdateEnc, err := vaultutils.EncryptSecretWithOrgID(jwtUpdateValue, vaultParsedPublicKey, orgID) + require.NoError(t, err) + + executeVaultSecretsCreateWithAuth(t, allowlistAuth, allowlistCreateEnc, allowlistSecretID, orgID, gwURL, []string{"main"}) + executeVaultSecretsCreateWithAuth(t, jwtAuth, jwtCreateEnc, jwtSecretID, orgID, gwURL, []string{"main"}) + workflowID := startVaultSecretsWorkflowPhasesTest(t, testEnv, "mixed-lifecycle", []vaultWorkflowPhase{ + { + Name: "mixed-created", + Checks: []vaultWorkflowCheck{ + {Name: "mixed-allowlist-create-get-main", SecretKey: allowlistSecretID, SecretNamespace: "main", ExpectedValue: allowlistCreateValue}, + {Name: "mixed-jwt-create-get-main", SecretKey: jwtSecretID, SecretNamespace: "main", ExpectedValue: jwtCreateValue}, + }, + }, + { + Name: "mixed-updated", + Checks: []vaultWorkflowCheck{ + {Name: "mixed-jwt-update-get-main", SecretKey: allowlistSecretID, SecretNamespace: "main", ExpectedValue: jwtUpdateValue}, + {Name: "mixed-allowlist-update-get-main", SecretKey: jwtSecretID, SecretNamespace: "main", ExpectedValue: allowlistUpdateValue}, + }, + }, + { + Name: "mixed-deleted", + Checks: []vaultWorkflowCheck{ + {Name: "mixed-allowlist-delete-not-found", SecretKey: allowlistSecretID, SecretNamespace: "main", ExpectNotFound: true}, + {Name: "mixed-jwt-delete-not-found", SecretKey: jwtSecretID, SecretNamespace: "main", ExpectNotFound: true}, + }, + }, + }) + waitForVaultWorkflowPhase(t, workflowID, "mixed-created", ulCh, bmCh) + + executeVaultSecretsUpdateWithAuth(t, jwtAuth, jwtUpdateEnc, allowlistSecretID, orgID, gwURL, []string{"main"}) + executeVaultSecretsUpdateWithAuth(t, allowlistAuth, allowlistUpdateEnc, jwtSecretID, orgID, gwURL, []string{"main"}) + waitForVaultWorkflowPhase(t, workflowID, "mixed-updated", ulCh, bmCh) + + executeVaultSecretsListWithAuth(t, allowlistAuth, []string{allowlistSecretID, jwtSecretID}, orgID, gwURL, "main") + executeVaultSecretsListWithAuth(t, jwtAuth, []string{allowlistSecretID, jwtSecretID}, orgID, gwURL, "main") + + executeVaultSecretsDeleteWithAuth(t, allowlistAuth, allowlistSecretID, orgID, gwURL, []string{"main"}) + executeVaultSecretsDeleteWithAuth(t, jwtAuth, jwtSecretID, orgID, gwURL, []string{"main"}) + waitForVaultWorkflowPhase(t, workflowID, "mixed-deleted", ulCh, bmCh) + }) + }) - framework.L.Info().Msg("Checking jsonResponse structure...") - var jsonResponse jsonrpc.Response[vaulttypes.SignedOCRResponse] - err = json.Unmarshal(httpResponseBody, &jsonResponse) - require.NoError(t, err, "failed to unmarshal getResponse") - framework.L.Info().Msgf("JSON Body: %v", jsonResponse) - if jsonResponse.Error != nil { - require.Empty(t, jsonResponse.Error.Error()) - } + t.Run("jwt_rejected_when_workflow_owner_missing", func(t *testing.T) { + executeVaultJWTSecretsCreateUnauthorizedTest(t, issuer, vaultPublicKey, orgID, "", gwURL, "missing workflow_owner in authorization_details") + }) +} - require.Equal(t, jsonrpc.JsonRpcVersion, jsonResponse.Version) - require.Equal(t, uniqueRequestID, jsonResponse.ID) - require.Equal(t, vaulttypes.MethodSecretsUpdate, jsonResponse.Method) +func ExecuteVaultJWTDisabledTest(t *testing.T, fixture *vaultScenarioFixture) { + t.Helper() + issuer := fixture.Issuer + gatewayURL := fixture.GatewayURL + vaultPublicKey := fixture.VaultPublicKey - signedOCRResponse := jsonResponse.Result - framework.L.Info().Msgf("Signed OCR Response: %s", signedOCRResponse.String()) + orgID := "org" + strings.ReplaceAll(uuid.NewString(), "-", "") + gwURL := gatewayURL.String() - // TODO: Verify the authenticity of this signed report, by ensuring that the signatures indeed match the payload + t.Run("jwt_with_workflow_owner_rejected_when_jwt_auth_disabled", func(t *testing.T) { + executeVaultJWTSecretsCreateUnauthorizedTest(t, issuer, vaultPublicKey, orgID, "0x1234567890abcdef1234567890abcdef12345678", gwURL, "JWTBasedAuth is disabled") + }) - updateSecretsResponse := vault_helpers.UpdateSecretsResponse{} - err = protojson.Unmarshal(signedOCRResponse.Payload, &updateSecretsResponse) - require.NoError(t, err, "failed to decode payload into UpdateSecretsResponse proto") - framework.L.Info().Msgf("UpdateSecretsResponse decoded as: %s", updateSecretsResponse.String()) + t.Run("jwt_without_workflow_owner_rejected_when_jwt_auth_disabled", func(t *testing.T) { + executeVaultJWTSecretsCreateUnauthorizedTest(t, issuer, vaultPublicKey, orgID, "", gwURL, "JWTBasedAuth is disabled") + }) +} - require.Len(t, updateSecretsResponse.Responses, len(namespaces)+1, "Expected one updated item per namespace plus one invalid item") - var foundInvalid bool - updateRespByNs := make(map[string]*vault_helpers.UpdateSecretResponse, len(namespaces)) - for _, r := range updateSecretsResponse.GetResponses() { - if r.GetId().GetKey() == "invalid" { - require.Contains(t, r.Error, "key does not exist") - foundInvalid = true - continue - } - updateRespByNs[r.GetId().GetNamespace()] = r - } - require.True(t, foundInvalid, "expected an error response for the 'invalid' key") - for _, namespace := range namespaces { - result, ok := updateRespByNs[namespace] - require.True(t, ok, "missing update response for namespace %s", namespace) - require.Empty(t, result.GetError()) - require.Equal(t, secretID, result.GetId().Key) - require.Equal(t, owner, result.GetId().Owner) +func TestVaultStaticTopologies_LoadExpectedConfig(t *testing.T) { + t.Parallel() + dockerHost := strings.TrimPrefix(framework.HostDockerInternal(), "http://") + + testCases := []struct { + name string + configPath string + wantJWTGate string + wantOrgGate string + wantLinking bool + }{ + { + name: "enabled", + configPath: vaultJWTAuthEnabledConfigPath, + wantJWTGate: "true", + wantOrgGate: "true", + wantLinking: false, + }, + { + name: "default", + configPath: vaultDefaultConfigPath, + wantJWTGate: "false", + wantOrgGate: "false", + wantLinking: false, + }, } - framework.L.Info().Msgf("Secrets updated successfully (namespaces=%v)", namespaces) -} + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cfg := &envconfig.Config{} + require.NoError(t, cfg.Load(t_helpers.GetTestConfig(t, tc.configPath).EnvironmentConfigPath)) -func executeVaultSecretsListTest(t *testing.T, secretID, owner, gatewayURL, namespace string, sethClient *seth.Client, wfRegistryContract *workflow_registry_v2_wrapper.WorkflowRegistry) { - framework.L.Info().Msgf("Listing secrets (namespace=%s)...", namespace) - uniqueRequestID := uuid.New().String() - secretsListRequest := vault_helpers.ListSecretIdentifiersRequest{ - RequestId: uniqueRequestID, - Owner: owner, - Namespace: namespace, - } - secretsListRequestBody, err := json.Marshal(secretsListRequest) //nolint:govet // The lock field is not set on this proto - require.NoError(t, err, "failed to marshal secrets request") - secretsUpdateRequestBodyJSON := json.RawMessage(secretsListRequestBody) - jsonRequest := jsonrpc.Request[json.RawMessage]{ - Version: jsonrpc.JsonRpcVersion, - ID: uniqueRequestID, - Method: vaulttypes.MethodSecretsList, - Params: &secretsUpdateRequestBodyJSON, - } - allowlistRequest(t, owner, jsonRequest, sethClient, wfRegistryContract) - - // Ensure that multiple requests can be allowlisted - uniqueRequestIDTwo := uuid.New().String() - secretsListRequestTwo := vault_helpers.ListSecretIdentifiersRequest{ - RequestId: uniqueRequestIDTwo, - Owner: owner, - Namespace: namespace, - } - secretsListRequestBodyTwo, err := json.Marshal(secretsListRequestTwo) //nolint:govet // The lock field is not set on this proto - require.NoError(t, err, "failed to marshal secrets request") - secretsUpdateRequestBodyJSONTwo := json.RawMessage(secretsListRequestBodyTwo) - jsonRequestTwo := jsonrpc.Request[json.RawMessage]{ - Version: jsonrpc.JsonRpcVersion, - ID: uniqueRequestIDTwo, - Method: vaulttypes.MethodSecretsList, - Params: &secretsUpdateRequestBodyJSONTwo, - } - allowlistRequest(t, owner, jsonRequestTwo, sethClient, wfRegistryContract) - - // Request 1 - requestBody, err := json.Marshal(jsonRequest) - require.NoError(t, err, "failed to marshal secrets request") - - statusCode, httpResponseBody := sendVaultRequestToGateway(t, gatewayURL, requestBody) - require.Equal(t, http.StatusOK, statusCode, "Gateway endpoint should respond with 200 OK") - var jsonResponse jsonrpc.Response[vaulttypes.SignedOCRResponse] - err = json.Unmarshal(httpResponseBody, &jsonResponse) - require.NoError(t, err, "failed to unmarshal getResponse") - framework.L.Info().Msgf("JSON Body: %v", jsonResponse) - if jsonResponse.Error != nil { - require.Empty(t, jsonResponse.Error.Error()) - } + for _, nodeSet := range cfg.NodeSets { + if nodeSet.Name != "workflow" && nodeSet.Name != "capabilities" { + continue + } + settingsRaw := nodeSet.EnvVars["CL_CRE_SETTINGS_DEFAULT"] + if settingsRaw == "" { + require.Equal(t, "false", tc.wantJWTGate) + require.Equal(t, "false", tc.wantOrgGate) + } else { + var settings map[string]string + require.NoError(t, json.Unmarshal([]byte(settingsRaw), &settings)) + require.Equal(t, tc.wantJWTGate, settings["VaultJWTAuthEnabled"]) + require.Equal(t, tc.wantOrgGate, settings["VaultOrgIdAsSecretOwnerEnabled"]) + } - require.Equal(t, jsonrpc.JsonRpcVersion, jsonResponse.Version) - require.Equal(t, uniqueRequestID, jsonResponse.ID) - require.Equal(t, vaulttypes.MethodSecretsList, jsonResponse.Method) - - signedOCRResponse := jsonResponse.Result - framework.L.Info().Msgf("Signed OCR Response: %s", signedOCRResponse.String()) - - // Request 2 - requestBodyTwo, err := json.Marshal(jsonRequestTwo) - require.NoError(t, err, "failed to marshal secrets request") - statusCodeTwo, httpResponseBodyTwo := sendVaultRequestToGateway(t, gatewayURL, requestBodyTwo) - require.Equal(t, http.StatusOK, statusCodeTwo, "Gateway endpoint should respond with 200 OK") - var jsonResponseTwo jsonrpc.Response[vaulttypes.SignedOCRResponse] - err = json.Unmarshal(httpResponseBodyTwo, &jsonResponseTwo) - require.NoError(t, err, "failed to unmarshal getResponse") - framework.L.Info().Msgf("JSON Body: %v", jsonResponseTwo) - if jsonResponseTwo.Error != nil { - require.Empty(t, jsonResponseTwo.Error.Error()) - } - require.Equal(t, jsonrpc.JsonRpcVersion, jsonResponseTwo.Version) - require.Equal(t, uniqueRequestIDTwo, jsonResponseTwo.ID) - require.Equal(t, vaulttypes.MethodSecretsList, jsonResponseTwo.Method) - signedOCRResponseTwo := jsonResponseTwo.Result - framework.L.Info().Msgf("Signed OCR Response: %s", signedOCRResponseTwo.String()) - - // TODO: Verify the authenticity of this signed report, by ensuring that the signatures indeed match the payload - - listSecretsResponse := vault_helpers.ListSecretIdentifiersResponse{} - err = protojson.Unmarshal(signedOCRResponse.Payload, &listSecretsResponse) - require.NoError(t, err, "failed to decode payload into ListSecretIdentifiersResponse proto") - framework.L.Info().Msgf("ListSecretIdentifiersResponse decoded as: %s", listSecretsResponse.String()) - - require.True(t, listSecretsResponse.Success, err) - require.GreaterOrEqual(t, len(listSecretsResponse.Identifiers), 1, "Expected at least one item in the response") - var keys = make([]string, 0, len(listSecretsResponse.Identifiers)) - for _, identifier := range listSecretsResponse.Identifiers { - keys = append(keys, identifier.Key) - require.Equal(t, owner, identifier.Owner) - require.Equal(t, namespace, identifier.Namespace) + for _, nodeSpec := range nodeSet.NodeSpecs { + if tc.wantLinking { + require.Contains(t, nodeSpec.Node.UserConfigOverrides, "[CRE.Linking]") + require.Contains(t, nodeSpec.Node.UserConfigOverrides, dockerHost+":18124") + continue + } + require.Empty(t, nodeSpec.Node.UserConfigOverrides) + } + } + }) } - require.Contains(t, keys, secretID) - framework.L.Info().Msgf("Secrets listed successfully (namespace=%s)", namespace) } -func executeVaultSecretsDeleteTest(t *testing.T, secretID, owner, gatewayURL string, namespaces []string, sethClient *seth.Client, wfRegistryContract *workflow_registry_v2_wrapper.WorkflowRegistry) { - framework.L.Info().Msgf("Deleting secrets (namespaces=%v)...", namespaces) - uniqueRequestID := uuid.New().String() +func TestMustMintVaultJWTForRequest_UsesRawRequestDigest(t *testing.T) { + issuer, err := vault.NewTestJWTIssuer() + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, issuer.Close()) + }) - deleteIDs := make([]*vault_helpers.SecretIdentifier, 0, len(namespaces)+1) - for _, namespace := range namespaces { - deleteIDs = append(deleteIDs, &vault_helpers.SecretIdentifier{ - Key: secretID, - Owner: owner, - Namespace: namespace, - }) - } - deleteIDs = append(deleteIDs, &vault_helpers.SecretIdentifier{ - Key: "invalid", - Owner: owner, - Namespace: namespaces[0], + params, err := json.Marshal(vault_helpers.CreateSecretsRequest{ + RequestId: "req-1", + EncryptedSecrets: []*vault_helpers.EncryptedSecret{ + { + Id: &vault_helpers.SecretIdentifier{ + Key: "9838", + Namespace: "main", + Owner: "org-123", + }, + EncryptedValue: "cipher+/==", + }, + }, }) + require.NoError(t, err) - secretsDeleteRequest := vault_helpers.DeleteSecretsRequest{ - RequestId: uniqueRequestID, - Ids: deleteIDs, - } - secretsDeleteRequestBody, err := json.Marshal(secretsDeleteRequest) //nolint:govet // The lock field is not set on this proto - require.NoError(t, err, "failed to marshal secrets request") - secretsDeleteRequestBodyJSON := json.RawMessage(secretsDeleteRequestBody) - jsonRequest := jsonrpc.Request[json.RawMessage]{ + rawParams := json.RawMessage(params) + req := jsonrpc.Request[json.RawMessage]{ Version: jsonrpc.JsonRpcVersion, - ID: uniqueRequestID, - Method: vaulttypes.MethodSecretsDelete, - Params: &secretsDeleteRequestBodyJSON, - } - allowlistRequest(t, owner, jsonRequest, sethClient, wfRegistryContract) - - requestBody, err := json.Marshal(jsonRequest) - require.NoError(t, err, "failed to marshal secrets request") - - statusCode, httpResponseBody := sendVaultRequestToGateway(t, gatewayURL, requestBody) - require.Equal(t, http.StatusOK, statusCode, "Gateway endpoint should respond with 200 OK") - framework.L.Info().Msg("Checking jsonResponse structure...") - var jsonResponse jsonrpc.Response[vaulttypes.SignedOCRResponse] - err = json.Unmarshal(httpResponseBody, &jsonResponse) - require.NoError(t, err, "failed to unmarshal getResponse") - framework.L.Info().Msgf("JSON Body: %v", jsonResponse) - if jsonResponse.Error != nil { - require.Empty(t, jsonResponse.Error.Error()) - } - - require.Equal(t, jsonrpc.JsonRpcVersion, jsonResponse.Version) - require.Equal(t, uniqueRequestID, jsonResponse.ID) - require.Equal(t, vaulttypes.MethodSecretsDelete, jsonResponse.Method) - - signedOCRResponse := jsonResponse.Result - framework.L.Info().Msgf("Signed OCR Response: %s", signedOCRResponse.String()) - - // TODO: Verify the authenticity of this signed report, by ensuring that the signatures indeed match the payload - - deleteSecretsResponse := vault_helpers.DeleteSecretsResponse{} - err = protojson.Unmarshal(signedOCRResponse.Payload, &deleteSecretsResponse) - require.NoError(t, err, "failed to decode payload into DeleteSecretResponse proto") - framework.L.Info().Msgf("DeleteSecretResponse decoded as: %s", deleteSecretsResponse.String()) - - require.Len(t, deleteSecretsResponse.Responses, len(namespaces)+1, "Expected one deleted item per namespace plus one invalid item") - var foundDeleteInvalid bool - deleteRespByNs := make(map[string]*vault_helpers.DeleteSecretResponse, len(namespaces)) - for _, r := range deleteSecretsResponse.GetResponses() { - if r.GetId().GetKey() == "invalid" { - require.Contains(t, r.Error, "key does not exist") - foundDeleteInvalid = true - continue - } - deleteRespByNs[r.GetId().GetNamespace()] = r - } - require.True(t, foundDeleteInvalid, "expected an error response for the 'invalid' key") - for _, namespace := range namespaces { - result, ok := deleteRespByNs[namespace] - require.True(t, ok, "missing delete response for namespace %s", namespace) - require.True(t, result.Success, result.Error) - require.Equal(t, owner, result.Id.Owner) - require.Equal(t, secretID, result.Id.Key) + ID: "req-1", + Method: vaulttypes.MethodSecretsCreate, + Params: &rawParams, } - - framework.L.Info().Msgf("Secrets deleted successfully (namespaces=%v)", namespaces) -} - -// updateVaultCapabilityConfigInRegistry updates the on-chain capabilities registry -// so that the vault@1.0.0 capability config includes DefaultConfig with VaultPublicKey -// and Threshold. This is required for workflows that call runtime.GetSecret(). -// Uses the original deployer key (not per-test key) since the registry is owned by the deployer. -func updateVaultCapabilityConfigInRegistry(t *testing.T, testEnv *ttypes.TestEnvironment, vaultPublicKey string) { - t.Helper() - testLogger := framework.L - testLogger.Info().Msg("Updating vault capability config in capabilities registry with VaultPublicKey...") - - capRegAddr := crecontracts.MustGetAddressFromDataStore( - testEnv.CreEnvironment.CldfEnvironment.DataStore, - testEnv.CreEnvironment.RegistryChainSelector, - keystone_changeset.CapabilitiesRegistry.String(), - testEnv.CreEnvironment.ContractVersions[keystone_changeset.CapabilitiesRegistry.String()], - "", - ) - - require.IsType(t, &evm.Blockchain{}, testEnv.CreEnvironment.Blockchains[0]) - sethClient := testEnv.CreEnvironment.Blockchains[0].(*evm.Blockchain).SethClient - - deployerClient, err := seth.NewClientBuilder(). - WithRpcUrl(sethClient.URL). - WithPrivateKeys([]string{ctfblockchain.DefaultAnvilPrivateKey}). - WithProtections(false, false, seth.MustMakeDuration(time.Second)). - Build() - require.NoError(t, err, "failed to create deployer seth client") - - capReg, err := capabilities_registry_v2.NewCapabilitiesRegistry( - common.HexToAddress(capRegAddr), deployerClient.Client, - ) - require.NoError(t, err, "failed to create capabilities registry wrapper") - - allDONs, err := capReg.GetDONs(&bind.CallOpts{}, big.NewInt(0), big.NewInt(100)) - require.NoError(t, err, "failed to get DONs from registry") - - var don *capabilities_registry_v2.CapabilitiesRegistryDONInfo - for i := range allDONs { - for _, cc := range allDONs[i].CapabilityConfigurations { - if cc.CapabilityId == "vault@1.0.0" { - don = &allDONs[i] - break - } - } - if don != nil { + req.Auth = mustMintVaultJWTForRequest(t, issuer, req, "org-123", "0xAbCdEf0123456789AbCdEf0123456789AbCdEf01") + + outboundReq := outboundRequestWithoutAuth(req) + requestDigest, err := outboundReq.Digest() + require.NoError(t, err) + + parsedToken, _, err := new(jwt.Parser).ParseUnverified(req.Auth, jwt.MapClaims{}) + require.NoError(t, err) + + claims, ok := parsedToken.Claims.(jwt.MapClaims) + require.True(t, ok) + authorizationDetails, ok := claims["authorization_details"].([]interface{}) + require.True(t, ok) + + var claimedDigest string + for _, detail := range authorizationDetails { + entry, ok := detail.(map[string]interface{}) + require.True(t, ok) + if entry["type"] == "request_digest" { + claimedDigest, ok = entry["value"].(string) + require.True(t, ok) break } } - require.NotNil(t, don, "could not find a DON with vault@1.0.0 capability in the registry") - testLogger.Info().Msgf("Found vault capability on DON %q (ID=%d)", don.Name, don.Id) - - newConfigs := make([]capabilities_registry_v2.CapabilitiesRegistryCapabilityConfiguration, 0, len(don.CapabilityConfigurations)) - for _, cc := range don.CapabilityConfigurations { - if cc.CapabilityId == "vault@1.0.0" { - existingConfig := &capabilitiespb.CapabilityConfig{} - if len(cc.Config) > 0 { - require.NoError(t, proto.Unmarshal(cc.Config, existingConfig), "failed to unmarshal existing vault capability config") - } - - vaultCfg := map[string]interface{}{ - "VaultPublicKey": vaultPublicKey, - "Threshold": 1, - } - valueMap, wrapErr := values.WrapMap(vaultCfg) - require.NoError(t, wrapErr, "failed to wrap vault config values") - - existingConfig.DefaultConfig = values.ProtoMap(valueMap) - - configBytes, marshalErr := proto.Marshal(existingConfig) - require.NoError(t, marshalErr, "failed to marshal updated vault capability config") - - cc.Config = configBytes - testLogger.Info().Msg("Injected VaultPublicKey and Threshold into vault@1.0.0 capability config") - } - newConfigs = append(newConfigs, cc) - } - - updateParams := capabilities_registry_v2.CapabilitiesRegistryUpdateDONParams{ - Name: don.Name, - Nodes: don.NodeP2PIds, - CapabilityConfigurations: newConfigs, - IsPublic: don.IsPublic, - F: don.F, - Config: don.Config, - } - - _, err = deployerClient.Decode(capReg.UpdateDONByName(deployerClient.NewTXOpts(), don.Name, updateParams)) - require.NoError(t, err, "UpdateDONByName tx failed") - testLogger.Info().Msg("Waiting for registry syncer to propagate the on-chain config change...") - time.Sleep(15 * time.Second) // registry syncer polls every 12s; one tick + margin -} - -func allowlistRequest(t *testing.T, owner string, request jsonrpc.Request[json.RawMessage], sethClient *seth.Client, wfRegistryContract *workflow_registry_v2_wrapper.WorkflowRegistry) { - requestDigest, err := request.Digest() - require.NoError(t, err, "failed to get digest for request") - requestDigestBytes, err := hex.DecodeString(requestDigest) - require.NoError(t, err, "failed to decode digest") - reqDigestBytes := [32]byte(requestDigestBytes) - _, err = wfRegistryContract.AllowlistRequest(sethClient.NewTXOpts(), reqDigestBytes, uint32(time.Now().Add(1*time.Hour).Unix())) //nolint:gosec // disable G115 - require.NoError(t, err, "failed to allowlist request") - - framework.L.Info().Msgf("Allowlisting request digest at contract %s, for owner: %s, digestHexStr: %s", wfRegistryContract.Address().Hex(), owner, requestDigest) - allowedList, err := wfRegistryContract.GetAllowlistedRequests(&bind.CallOpts{}, big.NewInt(0), big.NewInt(100)) - require.NoError(t, err, "failed to validate allowlisted request") - for _, req := range allowedList { - if req.RequestDigest == reqDigestBytes { - framework.L.Info().Msgf("Request digest found in allowlist") - } - framework.L.Info().Msgf("Allowlisted request digestHexStr: %s, owner: %s, expiry: %d", hex.EncodeToString(req.RequestDigest[:]), req.Owner.Hex(), req.ExpiryTimestamp) - } + require.NotEmpty(t, claimedDigest) + require.Equal(t, requestDigest, claimedDigest) } diff --git a/system-tests/tests/smoke/cre/v2_vault_don_test_helpers.go b/system-tests/tests/smoke/cre/v2_vault_don_test_helpers.go index 1b3976c857c..66cf024c963 100644 --- a/system-tests/tests/smoke/cre/v2_vault_don_test_helpers.go +++ b/system-tests/tests/smoke/cre/v2_vault_don_test_helpers.go @@ -2,20 +2,57 @@ package cre import ( "bytes" + "encoding/hex" "encoding/json" "io" + "math/big" + "math/rand" "net/http" + "net/url" + "slices" + "strconv" "strings" "testing" "time" + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" "github.com/google/uuid" + "github.com/smartcontractkit/tdh2/go/tdh2/tdh2easy" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + + "github.com/smartcontractkit/chainlink-protos/cre/go/values" vault_helpers "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault" + capabilitiespb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/pb" jsonrpc "github.com/smartcontractkit/chainlink-common/pkg/jsonrpc2" + capabilities_registry_v2 "github.com/smartcontractkit/chainlink-evm/gethwrappers/workflow/generated/capabilities_registry_wrapper_v2" + workflow_registry_v2_wrapper "github.com/smartcontractkit/chainlink-evm/gethwrappers/workflow/generated/workflow_registry_wrapper_v2" + commonevents "github.com/smartcontractkit/chainlink-protos/workflows/go/common" + workflowevents "github.com/smartcontractkit/chainlink-protos/workflows/go/events" "github.com/smartcontractkit/chainlink-testing-framework/framework" + ctfblockchain "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" + "github.com/smartcontractkit/chainlink-testing-framework/seth" + keystone_changeset "github.com/smartcontractkit/chainlink/deployment/keystone/changeset" + "github.com/smartcontractkit/chainlink/system-tests/lib/cre" + crecontracts "github.com/smartcontractkit/chainlink/system-tests/lib/cre/contracts" + "github.com/smartcontractkit/chainlink/system-tests/lib/cre/environment/blockchains/evm" + "github.com/smartcontractkit/chainlink/system-tests/lib/cre/vault" + creworkflow "github.com/smartcontractkit/chainlink/system-tests/lib/cre/workflow" + vaultsecret_config "github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/vaultsecret/config" + t_helpers "github.com/smartcontractkit/chainlink/system-tests/tests/test-helpers" + ttypes "github.com/smartcontractkit/chainlink/system-tests/tests/test-helpers/configuration" "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes" + "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaultutils" +) + +const ( + vaultDefaultConfigPath = "/configs/workflow-gateway-capabilities-don.toml" + vaultJWTAuthEnabledConfigPath = "/configs/workflow-gateway-capabilities-don-vault-jwt_auth-enabled.toml" + vaultJWTIssuerListenAddr = "0.0.0.0:18123" ) func FetchVaultPublicKey(t *testing.T, gatewayURL string) (publicKey string) { @@ -56,7 +93,24 @@ func FetchVaultPublicKey(t *testing.T, gatewayURL string) (publicKey string) { return publicKeyResponse.PublicKey } +func mustVaultPublicKey(t *testing.T, publicKey string) *tdh2easy.PublicKey { + t.Helper() + + publicKeyBytes, err := hex.DecodeString(publicKey) + require.NoError(t, err, "failed to decode vault public key") + + parsed := &tdh2easy.PublicKey{} + err = parsed.Unmarshal(publicKeyBytes) + require.NoError(t, err, "failed to unmarshal vault public key") + + return parsed +} + func sendVaultRequestToGateway(t *testing.T, gatewayURL string, requestBody []byte) (statusCode int, body []byte) { + return sendVaultRequestToGatewayWithHeaders(t, gatewayURL, requestBody, nil) +} + +func sendVaultRequestToGatewayWithHeaders(t *testing.T, gatewayURL string, requestBody []byte, headers map[string]string) (statusCode int, body []byte) { const maxRetries = 7 const retryInterval = 2 * time.Second @@ -68,6 +122,9 @@ func sendVaultRequestToGateway(t *testing.T, gatewayURL string, requestBody []by req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") + for key, value := range headers { + req.Header.Set(key, value) + } client := &http.Client{} resp, err := client.Do(req) @@ -110,3 +167,708 @@ func isGatewayNotAllowlistedError(body []byte) bool { return resp.Method == "" && resp.Error != nil && strings.Contains(resp.Error.Message, "request not allowlisted") } + +type vaultScenarioFixture struct { + TestEnv *ttypes.TestEnvironment + Issuer *vault.TestJWTIssuer + LinkingService *vault.TestLinkingService + GatewayURL *url.URL + VaultPublicKey string +} + +type vaultWorkflowCheck struct { + Name string + SecretKey string + SecretNamespace string + ExpectedValue string + ExpectNotFound bool +} + +type vaultWorkflowPhase struct { + Name string + Checks []vaultWorkflowCheck +} + +type vaultRequestAuth struct { + requestOwner string + authorize func(t *testing.T, req *jsonrpc.Request[json.RawMessage]) +} + +func getVaultJWTAuthEnabledTestConfig(t *testing.T) *ttypes.TestConfig { + t.Helper() + + return t_helpers.GetTestConfig(t, vaultJWTAuthEnabledConfigPath) +} + +func getVaultDefaultTestConfig(t *testing.T) *ttypes.TestConfig { + t.Helper() + + return t_helpers.GetTestConfig(t, vaultDefaultConfigPath) +} + +func isVaultJWTAuthEnabledTopology(topologyName string) bool { + return strings.Contains(topologyName, "vault-jwt_auth-enabled") +} + +func setupVaultScenarioFixture(t *testing.T, baseConfig *ttypes.TestConfig, usePerTestKeys bool) *vaultScenarioFixture { + t.Helper() + + issuer, err := vault.NewTestJWTIssuerOnAddr(vaultJWTIssuerListenAddr) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, issuer.Close()) + }) + + linkingService, err := vault.EnsureSharedTestLinkingServiceStarted() + require.NoError(t, err) + + var testEnv *ttypes.TestEnvironment + if usePerTestKeys { + testEnv = t_helpers.SetupTestEnvironmentWithPerTestKeys(t, baseConfig) + } else { + testEnv = t_helpers.SetupTestEnvironmentWithConfig(t, baseConfig) + } + + ensureVaultDKGResultPackages(t, testEnv) + gatewayURL := mustVaultGatewayURL(t, testEnv) + vaultPublicKey := FetchVaultPublicKey(t, gatewayURL.String()) + updateVaultCapabilityConfigInRegistry(t, testEnv, vaultPublicKey) + + return &vaultScenarioFixture{ + TestEnv: testEnv, + Issuer: issuer, + LinkingService: linkingService, + GatewayURL: gatewayURL, + VaultPublicKey: vaultPublicKey, + } +} + +func setupVaultSharedScenarioFixture(t *testing.T, baseConfig *ttypes.TestConfig) *vaultScenarioFixture { + t.Helper() + + return setupVaultScenarioFixture(t, baseConfig, false) +} + +func ensureVaultDKGResultPackages(t *testing.T, testEnv *ttypes.TestEnvironment) { + t.Helper() + + framework.L.Info().Msg("Ensuring DKG result packages are present...") + require.Eventually(t, func() bool { + for _, nodeSet := range testEnv.Config.NodeSets { + if slices.Contains(nodeSet.Capabilities, cre.VaultCapability) { + for i, node := range nodeSet.NodeSpecs { + if !slices.Contains(node.Roles, cre.BootstrapNode) { + packageCount, err := vault.GetResultPackageCount(t.Context(), i, nodeSet.DbInput.Port) + if err != nil || packageCount != 1 { + return false + } + } + } + return true + } + } + return false + }, time.Second*300, time.Second*5) +} + +func requireVaultLinkOwner(t *testing.T, sc *seth.Client, workflowRegistryAddr common.Address, version *semver.Version) { + t.Helper() + + err := creworkflow.LinkOwner(sc, workflowRegistryAddr, version) + if err != nil && !strings.Contains(err.Error(), "OwnershipLinkAlreadyExists") { + require.NoError(t, err) + } +} + +func mustVaultGatewayURL(t *testing.T, testEnv *ttypes.TestEnvironment) *url.URL { + t.Helper() + + framework.L.Info().Msg("Getting gateway configuration...") + require.NotEmpty(t, testEnv.Dons.GatewayConnectors.Configurations, "expected at least one gateway configuration") + gatewayURL, err := url.Parse(testEnv.Dons.GatewayConnectors.Configurations[0].Incoming.Protocol + "://" + testEnv.Dons.GatewayConnectors.Configurations[0].Incoming.Host + ":" + strconv.Itoa(testEnv.Dons.GatewayConnectors.Configurations[0].Incoming.ExternalPort) + testEnv.Dons.GatewayConnectors.Configurations[0].Incoming.Path) + require.NoError(t, err, "failed to parse gateway URL") + framework.L.Info().Msgf("Gateway URL: %s", gatewayURL.String()) + return gatewayURL +} + +func newAllowlistVaultRequestAuth(requestOwner string, sethClient *seth.Client, wfRegistryContract *workflow_registry_v2_wrapper.WorkflowRegistry) vaultRequestAuth { + return vaultRequestAuth{ + requestOwner: requestOwner, + authorize: func(t *testing.T, req *jsonrpc.Request[json.RawMessage]) { + allowlistRequest(t, requestOwner, *req, sethClient, wfRegistryContract) + }, + } +} + +func newJWTVaultRequestAuth(issuer *vault.TestJWTIssuer, orgID, workflowOwner string) vaultRequestAuth { + return vaultRequestAuth{ + requestOwner: orgID, + authorize: func(t *testing.T, req *jsonrpc.Request[json.RawMessage]) { + req.Auth = mustMintVaultJWTForRequest(t, issuer, *req, orgID, workflowOwner) + }, + } +} + +func (a vaultRequestAuth) apply(t *testing.T, req *jsonrpc.Request[json.RawMessage]) { + t.Helper() + if a.authorize != nil { + a.authorize(t, req) + } +} + +func newVaultJSONRequest(t *testing.T, requestID, method string, params any) jsonrpc.Request[json.RawMessage] { + t.Helper() + + requestBody, err := json.Marshal(params) + require.NoError(t, err, "failed to marshal secrets request") + requestBodyJSON := json.RawMessage(requestBody) + + return jsonrpc.Request[json.RawMessage]{ + Version: jsonrpc.JsonRpcVersion, + ID: requestID, + Method: method, + Params: &requestBodyJSON, + } +} + +func buildEncryptedSecrets(secretID, owner, encryptedSecret string, namespaces []string) []*vault_helpers.EncryptedSecret { + encryptedSecrets := make([]*vault_helpers.EncryptedSecret, 0, len(namespaces)) + for _, namespace := range namespaces { + encryptedSecrets = append(encryptedSecrets, &vault_helpers.EncryptedSecret{ + Id: &vault_helpers.SecretIdentifier{ + Key: secretID, + Owner: owner, + Namespace: namespace, + }, + EncryptedValue: encryptedSecret, + }) + } + + return encryptedSecrets +} + +func buildSecretIdentifiers(secretID, owner string, namespaces []string) []*vault_helpers.SecretIdentifier { + identifiers := make([]*vault_helpers.SecretIdentifier, 0, len(namespaces)) + for _, namespace := range namespaces { + identifiers = append(identifiers, &vault_helpers.SecretIdentifier{ + Key: secretID, + Owner: owner, + Namespace: namespace, + }) + } + + return identifiers +} + +func sendVaultSignedOCRRequestToGateway(t *testing.T, gatewayURL string, jsonRequest jsonrpc.Request[json.RawMessage]) jsonrpc.Response[vaulttypes.SignedOCRResponse] { + t.Helper() + + authToken := jsonRequest.Auth + jsonRequest = outboundRequestWithoutAuth(jsonRequest) + + requestBody, err := json.Marshal(jsonRequest) + require.NoError(t, err, "failed to marshal vault request") + + headers := map[string]string{} + if authToken != "" { + headers["Authorization"] = "Bearer " + authToken + } + + statusCode, httpResponseBody := sendVaultRequestToGatewayWithHeaders(t, gatewayURL, requestBody, headers) + require.Equal(t, http.StatusOK, statusCode, "Gateway endpoint should respond with 200 OK") + + var jsonResponse jsonrpc.Response[vaulttypes.SignedOCRResponse] + err = json.Unmarshal(httpResponseBody, &jsonResponse) + require.NoError(t, err, "failed to unmarshal gateway response") + if jsonResponse.Error != nil { + require.Empty(t, jsonResponse.Error.Error()) + } + require.Equal(t, jsonrpc.JsonRpcVersion, jsonResponse.Version) + + return jsonResponse +} + +func executeVaultSecretsCreateWithAuth(t *testing.T, auth vaultRequestAuth, encryptedSecret, secretID, expectedResponseOwner, gatewayURL string, namespaces []string) { + t.Helper() + + framework.L.Info().Msgf("Creating secrets (namespaces=%v)...", namespaces) + + uniqueRequestID := uuid.New().String() + secretsCreateRequest := vault_helpers.CreateSecretsRequest{ + RequestId: uniqueRequestID, + EncryptedSecrets: buildEncryptedSecrets(secretID, auth.requestOwner, encryptedSecret, namespaces), + } + jsonRequest := newVaultJSONRequest(t, uniqueRequestID, vaulttypes.MethodSecretsCreate, &secretsCreateRequest) + auth.apply(t, &jsonRequest) + + jsonResponse := sendVaultSignedOCRRequestToGateway(t, gatewayURL, jsonRequest) + require.Equal(t, uniqueRequestID, jsonResponse.ID) + require.Equal(t, vaulttypes.MethodSecretsCreate, jsonResponse.Method) + + createSecretsResponse := vault_helpers.CreateSecretsResponse{} + err := protojson.Unmarshal(jsonResponse.Result.Payload, &createSecretsResponse) + require.NoError(t, err, "failed to decode payload into CreateSecretsResponse proto") + + require.Len(t, createSecretsResponse.Responses, len(namespaces), "Expected one item in the response per namespace") + respByNs := make(map[string]*vault_helpers.CreateSecretResponse, len(namespaces)) + for _, r := range createSecretsResponse.GetResponses() { + respByNs[r.GetId().GetNamespace()] = r + } + for _, namespace := range namespaces { + result, ok := respByNs[namespace] + require.True(t, ok, "missing response for namespace %s", namespace) + require.Empty(t, result.GetError()) + require.Equal(t, secretID, result.GetId().Key) + require.Equal(t, expectedResponseOwner, result.GetId().Owner) + } +} + +func executeVaultSecretsUpdateWithAuth(t *testing.T, auth vaultRequestAuth, encryptedSecret, secretID, expectedResponseOwner, gatewayURL string, namespaces []string) { + t.Helper() + + framework.L.Info().Msgf("Updating secrets (namespaces=%v)...", namespaces) + require.NotEmpty(t, namespaces, "namespaces must not be empty") + + encryptedSecrets := buildEncryptedSecrets(secretID, auth.requestOwner, encryptedSecret, namespaces) + encryptedSecrets = append(encryptedSecrets, &vault_helpers.EncryptedSecret{ + Id: &vault_helpers.SecretIdentifier{ + Key: "invalid", + Owner: auth.requestOwner, + Namespace: namespaces[0], + }, + EncryptedValue: encryptedSecret, + }) + + uniqueRequestID := uuid.New().String() + secretsUpdateRequest := vault_helpers.UpdateSecretsRequest{ + RequestId: uniqueRequestID, + EncryptedSecrets: encryptedSecrets, + } + jsonRequest := newVaultJSONRequest(t, uniqueRequestID, vaulttypes.MethodSecretsUpdate, &secretsUpdateRequest) + auth.apply(t, &jsonRequest) + + jsonResponse := sendVaultSignedOCRRequestToGateway(t, gatewayURL, jsonRequest) + require.Equal(t, uniqueRequestID, jsonResponse.ID) + require.Equal(t, vaulttypes.MethodSecretsUpdate, jsonResponse.Method) + + updateSecretsResponse := vault_helpers.UpdateSecretsResponse{} + err := protojson.Unmarshal(jsonResponse.Result.Payload, &updateSecretsResponse) + require.NoError(t, err, "failed to decode payload into UpdateSecretsResponse proto") + + require.Len(t, updateSecretsResponse.Responses, len(namespaces)+1, "Expected one updated item per namespace plus one invalid item") + var foundInvalid bool + updateRespByNs := make(map[string]*vault_helpers.UpdateSecretResponse, len(namespaces)) + for _, r := range updateSecretsResponse.GetResponses() { + if r.GetId().GetKey() == "invalid" { + require.Contains(t, r.Error, "key does not exist") + foundInvalid = true + continue + } + updateRespByNs[r.GetId().GetNamespace()] = r + } + require.True(t, foundInvalid, "expected an error response for the 'invalid' key") + for _, namespace := range namespaces { + result, ok := updateRespByNs[namespace] + require.True(t, ok, "missing update response for namespace %s", namespace) + require.Empty(t, result.GetError()) + require.Equal(t, secretID, result.GetId().Key) + require.Equal(t, expectedResponseOwner, result.GetId().Owner) + } +} + +func executeVaultSecretsListWithAuth(t *testing.T, auth vaultRequestAuth, expectedKeys []string, expectedOwner, gatewayURL, namespace string) { + t.Helper() + + framework.L.Info().Msgf("Listing secrets (namespace=%s)...", namespace) + + uniqueRequestID := uuid.New().String() + secretsListRequest := vault_helpers.ListSecretIdentifiersRequest{ + RequestId: uniqueRequestID, + Owner: auth.requestOwner, + Namespace: namespace, + } + jsonRequest := newVaultJSONRequest(t, uniqueRequestID, vaulttypes.MethodSecretsList, &secretsListRequest) + auth.apply(t, &jsonRequest) + + jsonResponse := sendVaultSignedOCRRequestToGateway(t, gatewayURL, jsonRequest) + require.Equal(t, uniqueRequestID, jsonResponse.ID) + require.Equal(t, vaulttypes.MethodSecretsList, jsonResponse.Method) + + listSecretsResponse := vault_helpers.ListSecretIdentifiersResponse{} + err := protojson.Unmarshal(jsonResponse.Result.Payload, &listSecretsResponse) + require.NoError(t, err, "failed to decode payload into ListSecretIdentifiersResponse proto") + + require.True(t, listSecretsResponse.Success, err) + require.GreaterOrEqual(t, len(listSecretsResponse.Identifiers), len(expectedKeys), "Expected enough identifiers in the response") + keys := make([]string, 0, len(listSecretsResponse.Identifiers)) + for _, identifier := range listSecretsResponse.Identifiers { + keys = append(keys, identifier.Key) + require.Equal(t, expectedOwner, identifier.Owner) + require.Equal(t, namespace, identifier.Namespace) + } + for _, secretID := range expectedKeys { + require.Contains(t, keys, secretID) + } +} + +func executeVaultSecretsDeleteWithAuth(t *testing.T, auth vaultRequestAuth, secretID, expectedResponseOwner, gatewayURL string, namespaces []string) { + t.Helper() + + framework.L.Info().Msgf("Deleting secrets (namespaces=%v)...", namespaces) + require.NotEmpty(t, namespaces, "namespaces must not be empty") + + deleteIDs := buildSecretIdentifiers(secretID, auth.requestOwner, namespaces) + deleteIDs = append(deleteIDs, &vault_helpers.SecretIdentifier{ + Key: "invalid", + Owner: auth.requestOwner, + Namespace: namespaces[0], + }) + + uniqueRequestID := uuid.New().String() + secretsDeleteRequest := vault_helpers.DeleteSecretsRequest{ + RequestId: uniqueRequestID, + Ids: deleteIDs, + } + jsonRequest := newVaultJSONRequest(t, uniqueRequestID, vaulttypes.MethodSecretsDelete, &secretsDeleteRequest) + auth.apply(t, &jsonRequest) + + jsonResponse := sendVaultSignedOCRRequestToGateway(t, gatewayURL, jsonRequest) + require.Equal(t, uniqueRequestID, jsonResponse.ID) + require.Equal(t, vaulttypes.MethodSecretsDelete, jsonResponse.Method) + + deleteSecretsResponse := vault_helpers.DeleteSecretsResponse{} + err := protojson.Unmarshal(jsonResponse.Result.Payload, &deleteSecretsResponse) + require.NoError(t, err, "failed to decode payload into DeleteSecretResponse proto") + + require.Len(t, deleteSecretsResponse.Responses, len(namespaces)+1, "Expected one deleted item per namespace plus one invalid item") + var foundDeleteInvalid bool + deleteRespByNs := make(map[string]*vault_helpers.DeleteSecretResponse, len(namespaces)) + for _, r := range deleteSecretsResponse.GetResponses() { + if r.GetId().GetKey() == "invalid" { + require.Contains(t, r.Error, "key does not exist") + foundDeleteInvalid = true + continue + } + deleteRespByNs[r.GetId().GetNamespace()] = r + } + require.True(t, foundDeleteInvalid, "expected an error response for the 'invalid' key") + for _, namespace := range namespaces { + result, ok := deleteRespByNs[namespace] + require.True(t, ok, "missing delete response for namespace %s", namespace) + require.True(t, result.Success, result.Error) + require.Equal(t, expectedResponseOwner, result.Id.Owner) + require.Equal(t, secretID, result.Id.Key) + } +} + +func executeVaultAllowListSecretsCreateTest(t *testing.T, encryptedSecret, secretID, requestOwner, expectedResponseOwner, gatewayURL string, namespaces []string, sethClient *seth.Client, wfRegistryContract *workflow_registry_v2_wrapper.WorkflowRegistry) { + auth := newAllowlistVaultRequestAuth(requestOwner, sethClient, wfRegistryContract) + executeVaultSecretsCreateWithAuth(t, auth, encryptedSecret, secretID, expectedResponseOwner, gatewayURL, namespaces) +} + +func executeVaultJWTSecretsCreateTest(t *testing.T, issuer *vault.TestJWTIssuer, encryptedSecret, secretID, orgID, workflowOwner, gatewayURL string, namespaces []string) { + t.Helper() + + auth := newJWTVaultRequestAuth(issuer, orgID, workflowOwner) + executeVaultSecretsCreateWithAuth(t, auth, encryptedSecret, secretID, orgID, gatewayURL, namespaces) +} + +func executeVaultJWTSecretsListTest(t *testing.T, issuer *vault.TestJWTIssuer, secretID, orgID, workflowOwner, gatewayURL, namespace string) { + t.Helper() + + auth := newJWTVaultRequestAuth(issuer, orgID, workflowOwner) + executeVaultSecretsListWithAuth(t, auth, []string{secretID}, orgID, gatewayURL, namespace) +} + +func executeVaultJWTSecretsDeleteTest(t *testing.T, issuer *vault.TestJWTIssuer, secretID, orgID, workflowOwner, gatewayURL string, namespaces []string) { + t.Helper() + + auth := newJWTVaultRequestAuth(issuer, orgID, workflowOwner) + executeVaultSecretsDeleteWithAuth(t, auth, secretID, orgID, gatewayURL, namespaces) +} + +func mustMintVaultJWTForRequest(t *testing.T, issuer *vault.TestJWTIssuer, req jsonrpc.Request[json.RawMessage], orgID, workflowOwner string) string { + t.Helper() + + outboundReq := outboundRequestWithoutAuth(req) + requestDigest, err := outboundReq.Digest() + require.NoError(t, err, "failed to compute request digest") + + token, err := issuer.MintToken(vault.JWTTokenClaims{ + KeyID: vault.DefaultJWTIssuerKeyID, + Issuer: issuer.DockerIssuerURL(), + Audience: vault.DefaultJWTAudience, + OrgID: orgID, + WorkflowOwner: workflowOwner, + RequestDigest: requestDigest, + }) + require.NoError(t, err, "failed to mint JWT") + + return token +} + +func sendVaultJWTRequestToGatewayExpectError(t *testing.T, gatewayURL string, jsonRequest jsonrpc.Request[json.RawMessage], wantStatus int) jsonrpc.Response[json.RawMessage] { + t.Helper() + + authToken := jsonRequest.Auth + jsonRequest = outboundRequestWithoutAuth(jsonRequest) + + requestBody, err := json.Marshal(jsonRequest) + require.NoError(t, err, "failed to marshal JWT-authenticated request") + + headers := map[string]string{} + if authToken != "" { + headers["Authorization"] = "Bearer " + authToken + } + + statusCode, httpResponseBody := sendVaultRequestToGatewayWithHeaders(t, gatewayURL, requestBody, headers) + require.Equal(t, wantStatus, statusCode, "Gateway endpoint should respond with the expected error status") + + var jsonResponse jsonrpc.Response[json.RawMessage] + err = json.Unmarshal(httpResponseBody, &jsonResponse) + require.NoError(t, err, "failed to unmarshal gateway error response") + require.Equal(t, jsonrpc.JsonRpcVersion, jsonResponse.Version) + + return jsonResponse +} + +func outboundRequestWithoutAuth(req jsonrpc.Request[json.RawMessage]) jsonrpc.Request[json.RawMessage] { + req.Auth = "" + return req +} + +func executeVaultJWTSecretsCreateUnauthorizedTest( + t *testing.T, + issuer *vault.TestJWTIssuer, + vaultPublicKey, orgID, workflowOwner, gatewayURL string, + expectedAuthError string, +) { + t.Helper() + + secretID := strconv.Itoa(rand.Intn(10000)) + encryptedSecret, err := vaultutils.EncryptSecretWithOrgID("secret-jwt-disabled", mustVaultPublicKey(t, vaultPublicKey), orgID) + require.NoError(t, err) + + uniqueRequestID := uuid.New().String() + secretsCreateRequest := vault_helpers.CreateSecretsRequest{ + RequestId: uniqueRequestID, + EncryptedSecrets: []*vault_helpers.EncryptedSecret{{ + Id: &vault_helpers.SecretIdentifier{ + Key: secretID, + Owner: orgID, + Namespace: "main", + }, + EncryptedValue: encryptedSecret, + }}, + } + jsonRequest := newVaultJSONRequest(t, uniqueRequestID, vaulttypes.MethodSecretsCreate, &secretsCreateRequest) + jsonRequest.Auth = mustMintVaultJWTForRequest(t, issuer, jsonRequest, orgID, workflowOwner) + + jsonResponse := sendVaultJWTRequestToGatewayExpectError(t, gatewayURL, jsonRequest, http.StatusBadRequest) + require.Equal(t, uniqueRequestID, jsonResponse.ID) + require.Empty(t, jsonResponse.Method) + require.NotNil(t, jsonResponse.Error) + require.Contains(t, jsonResponse.Error.Error(), "request not authorized") + require.Contains(t, jsonResponse.Error.Error(), expectedAuthError) +} + +func executeVaultSecretsWorkflowChecksTest( + t *testing.T, testEnv *ttypes.TestEnvironment, + workflowBaseName string, + checks []vaultWorkflowCheck, + userLogsCh chan *workflowevents.UserLogs, baseMessageCh chan *commonevents.BaseMessage, +) { + t.Helper() + + workflowID := startVaultSecretsWorkflowPhasesTest(t, testEnv, workflowBaseName, []vaultWorkflowPhase{{ + Name: workflowBaseName, + Checks: checks, + }}) + waitForVaultWorkflowPhase(t, workflowID, workflowBaseName, userLogsCh, baseMessageCh) +} + +func startVaultSecretsWorkflowPhasesTest( + t *testing.T, testEnv *ttypes.TestEnvironment, + workflowBaseName string, + phases []vaultWorkflowPhase, +) string { + t.Helper() + + testLogger := framework.L + testLogger.Info(). + Str("workflow_base_name", workflowBaseName). + Int("phase_count", len(phases)). + Msg("Starting vault workflow phase verification") + + workflowName := t_helpers.UniqueWorkflowName(testEnv, workflowBaseName) + cfgPhases := make([]vaultsecret_config.Phase, 0, len(phases)) + for _, phase := range phases { + cfgChecks := make([]vaultsecret_config.Check, 0, len(phase.Checks)) + for _, check := range phase.Checks { + cfgChecks = append(cfgChecks, vaultsecret_config.Check{ + Name: check.Name, + SecretKey: check.SecretKey, + SecretNamespace: check.SecretNamespace, + ExpectedValue: check.ExpectedValue, + ExpectNotFound: check.ExpectNotFound, + }) + } + cfgPhases = append(cfgPhases, vaultsecret_config.Phase{ + Name: phase.Name, + Checks: cfgChecks, + }) + } + + cfg := &vaultsecret_config.Config{Phases: cfgPhases} + const workflowFileLocation = "./vaultsecret/main.go" + return t_helpers.CompileAndDeployWorkflow(t, testEnv, testLogger, workflowName, cfg, workflowFileLocation) +} + +func waitForVaultWorkflowPhase( + t *testing.T, + workflowID, phaseName string, + userLogsCh chan *workflowevents.UserLogs, + baseMessageCh chan *commonevents.BaseMessage, +) { + t.Helper() + + testLogger := framework.L + t_helpers.WatchWorkflowLogs( + t, + testLogger, + userLogsCh, + baseMessageCh, + t_helpers.WorkflowEngineInitErrorLog, + "Vault secret workflow phase completed: "+phaseName, + 4*time.Minute, + t_helpers.WithUserLogWorkflowID(workflowID), + ) + testLogger.Info().Str("phase_name", phaseName).Msg("Vault secret workflow phase completed") +} + +func executeVaultSecretsUpdateTest(t *testing.T, encryptedSecret, secretID, requestOwner, expectedResponseOwner, gatewayURL string, namespaces []string, sethClient *seth.Client, wfRegistryContract *workflow_registry_v2_wrapper.WorkflowRegistry) { + auth := newAllowlistVaultRequestAuth(requestOwner, sethClient, wfRegistryContract) + executeVaultSecretsUpdateWithAuth(t, auth, encryptedSecret, secretID, expectedResponseOwner, gatewayURL, namespaces) +} + +func executeVaultSecretsListTest(t *testing.T, secretID, requestOwner, expectedOwner, gatewayURL, namespace string, sethClient *seth.Client, wfRegistryContract *workflow_registry_v2_wrapper.WorkflowRegistry) { + auth := newAllowlistVaultRequestAuth(requestOwner, sethClient, wfRegistryContract) + executeVaultSecretsListWithAuth(t, auth, []string{secretID}, expectedOwner, gatewayURL, namespace) +} + +func executeVaultSecretsDeleteTest(t *testing.T, secretID, requestOwner, expectedResponseOwner, gatewayURL string, namespaces []string, sethClient *seth.Client, wfRegistryContract *workflow_registry_v2_wrapper.WorkflowRegistry) { + auth := newAllowlistVaultRequestAuth(requestOwner, sethClient, wfRegistryContract) + executeVaultSecretsDeleteWithAuth(t, auth, secretID, expectedResponseOwner, gatewayURL, namespaces) +} + +// updateVaultCapabilityConfigInRegistry updates the on-chain capabilities registry +// so that the vault@1.0.0 capability config includes DefaultConfig with VaultPublicKey +// and Threshold. This is required for workflows that call runtime.GetSecret(). +// Uses the original deployer key (not per-test key) since the registry is owned by the deployer. +func updateVaultCapabilityConfigInRegistry(t *testing.T, testEnv *ttypes.TestEnvironment, vaultPublicKey string) { + t.Helper() + testLogger := framework.L + testLogger.Info().Msg("Updating vault capability config in capabilities registry with VaultPublicKey...") + + capRegAddr := crecontracts.MustGetAddressFromDataStore( + testEnv.CreEnvironment.CldfEnvironment.DataStore, + testEnv.CreEnvironment.RegistryChainSelector, + keystone_changeset.CapabilitiesRegistry.String(), + testEnv.CreEnvironment.ContractVersions[keystone_changeset.CapabilitiesRegistry.String()], + "", + ) + + require.IsType(t, &evm.Blockchain{}, testEnv.CreEnvironment.Blockchains[0]) + sethClient := testEnv.CreEnvironment.Blockchains[0].(*evm.Blockchain).SethClient + + deployerClient, err := seth.NewClientBuilder(). + WithRpcUrl(sethClient.URL). + WithPrivateKeys([]string{ctfblockchain.DefaultAnvilPrivateKey}). + WithProtections(false, false, seth.MustMakeDuration(time.Second)). + Build() + require.NoError(t, err, "failed to create deployer seth client") + + capReg, err := capabilities_registry_v2.NewCapabilitiesRegistry( + common.HexToAddress(capRegAddr), deployerClient.Client, + ) + require.NoError(t, err, "failed to create capabilities registry wrapper") + + allDONs, err := capReg.GetDONs(&bind.CallOpts{}, big.NewInt(0), big.NewInt(100)) + require.NoError(t, err, "failed to get DONs from registry") + + var don *capabilities_registry_v2.CapabilitiesRegistryDONInfo + for i := range allDONs { + for _, cc := range allDONs[i].CapabilityConfigurations { + if cc.CapabilityId == "vault@1.0.0" { + don = &allDONs[i] + break + } + } + if don != nil { + break + } + } + require.NotNil(t, don, "could not find a DON with vault@1.0.0 capability in the registry") + testLogger.Info().Msgf("Found vault capability on DON %q (ID=%d)", don.Name, don.Id) + + newConfigs := make([]capabilities_registry_v2.CapabilitiesRegistryCapabilityConfiguration, 0, len(don.CapabilityConfigurations)) + for _, cc := range don.CapabilityConfigurations { + if cc.CapabilityId == "vault@1.0.0" { + existingConfig := &capabilitiespb.CapabilityConfig{} + if len(cc.Config) > 0 { + require.NoError(t, proto.Unmarshal(cc.Config, existingConfig), "failed to unmarshal existing vault capability config") + } + + vaultCfg := map[string]interface{}{ + "VaultPublicKey": vaultPublicKey, + "Threshold": 1, + } + valueMap, wrapErr := values.WrapMap(vaultCfg) + require.NoError(t, wrapErr, "failed to wrap vault config values") + + existingConfig.DefaultConfig = values.ProtoMap(valueMap) + + configBytes, marshalErr := proto.Marshal(existingConfig) + require.NoError(t, marshalErr, "failed to marshal updated vault capability config") + + cc.Config = configBytes + testLogger.Info().Msg("Injected VaultPublicKey and Threshold into vault@1.0.0 capability config") + } + newConfigs = append(newConfigs, cc) + } + + updateParams := capabilities_registry_v2.CapabilitiesRegistryUpdateDONParams{ + Name: don.Name, + Nodes: don.NodeP2PIds, + CapabilityConfigurations: newConfigs, + IsPublic: don.IsPublic, + F: don.F, + Config: don.Config, + } + + _, err = deployerClient.Decode(capReg.UpdateDONByName(deployerClient.NewTXOpts(), don.Name, updateParams)) + require.NoError(t, err, "UpdateDONByName tx failed") + + testLogger.Info().Msg("Waiting for registry syncer to propagate the on-chain config change...") + time.Sleep(15 * time.Second) // registry syncer polls every 12s; one tick + margin +} + +func allowlistRequest(t *testing.T, owner string, request jsonrpc.Request[json.RawMessage], sethClient *seth.Client, wfRegistryContract *workflow_registry_v2_wrapper.WorkflowRegistry) { + requestDigest, err := request.Digest() + require.NoError(t, err, "failed to get digest for request") + requestDigestBytes, err := hex.DecodeString(requestDigest) + require.NoError(t, err, "failed to decode digest") + reqDigestBytes := [32]byte(requestDigestBytes) + _, err = wfRegistryContract.AllowlistRequest(sethClient.NewTXOpts(), reqDigestBytes, uint32(time.Now().Add(1*time.Hour).Unix())) //nolint:gosec // disable G115 + require.NoError(t, err, "failed to allowlist request") + + framework.L.Info().Msgf("Allowlisting request digest at contract %s, for owner: %s, digestHexStr: %s", wfRegistryContract.Address().Hex(), owner, requestDigest) + allowedList, err := wfRegistryContract.GetAllowlistedRequests(&bind.CallOpts{}, big.NewInt(0), big.NewInt(100)) + require.NoError(t, err, "failed to validate allowlisted request") + for _, req := range allowedList { + if req.RequestDigest == reqDigestBytes { + framework.L.Info().Msgf("Request digest found in allowlist") + } + framework.L.Info().Msgf("Allowlisted request digestHexStr: %s, owner: %s, expiry: %d", hex.EncodeToString(req.RequestDigest[:]), req.Owner.Hex(), req.ExpiryTimestamp) + } +} diff --git a/system-tests/tests/smoke/cre/vaultsecret/config/config.go b/system-tests/tests/smoke/cre/vaultsecret/config/config.go index 9b057eb7d17..bde2a4c1b38 100644 --- a/system-tests/tests/smoke/cre/vaultsecret/config/config.go +++ b/system-tests/tests/smoke/cre/vaultsecret/config/config.go @@ -1,7 +1,58 @@ package config -type Config struct { +type Check struct { + Name string `yaml:"name,omitempty"` SecretKey string `yaml:"secretKey"` SecretNamespace string `yaml:"secretNamespace"` + ExpectedValue string `yaml:"expectedValue,omitempty"` ExpectNotFound bool `yaml:"expectNotFound"` } + +type Phase struct { + Name string `yaml:"name"` + Checks []Check `yaml:"checks"` +} + +type Config struct { + Phases []Phase `yaml:"phases"` + Checks []Check `yaml:"checks"` + + // Legacy single-check fields kept for compatibility with any older callers. + SecretKey string `yaml:"secretKey,omitempty"` + SecretNamespace string `yaml:"secretNamespace,omitempty"` + ExpectedValue string `yaml:"expectedValue,omitempty"` + ExpectNotFound bool `yaml:"expectNotFound,omitempty"` +} + +func (c Config) EffectiveChecks() []Check { + if len(c.Checks) > 0 { + return c.Checks + } + + if c.SecretKey == "" && c.SecretNamespace == "" { + return nil + } + + return []Check{{ + SecretKey: c.SecretKey, + SecretNamespace: c.SecretNamespace, + ExpectedValue: c.ExpectedValue, + ExpectNotFound: c.ExpectNotFound, + }} +} + +func (c Config) EffectivePhases() []Phase { + if len(c.Phases) > 0 { + return c.Phases + } + + checks := c.EffectiveChecks() + if len(checks) == 0 { + return nil + } + + return []Phase{{ + Name: "default", + Checks: checks, + }} +} diff --git a/system-tests/tests/smoke/cre/vaultsecret/main.go b/system-tests/tests/smoke/cre/vaultsecret/main.go index b1233563cbe..dadd89c30db 100644 --- a/system-tests/tests/smoke/cre/vaultsecret/main.go +++ b/system-tests/tests/smoke/cre/vaultsecret/main.go @@ -36,41 +36,84 @@ func RunVaultSecretWorkflow(cfg config.Config, _ *slog.Logger, _ cre.SecretsProv } func onTrigger(cfg config.Config, runtime cre.Runtime, _ *cron.Payload) (string, error) { - runtime.Logger().Info("Vault secret workflow triggered", - "secretKey", cfg.SecretKey, - "secretNamespace", cfg.SecretNamespace, - "expectNotFound", cfg.ExpectNotFound, - ) - - secret, err := runtime.GetSecret(&cre.SecretRequest{ - Namespace: cfg.SecretNamespace, - Id: cfg.SecretKey, - }).Await() - - if cfg.ExpectNotFound { - if err != nil && strings.Contains(err.Error(), "key does not exist") { - runtime.Logger().Info("Vault secret correctly not found after deletion", "secretKey", cfg.SecretKey) - return fmt.Sprintf("Secret correctly not found: key=%s", cfg.SecretKey), nil - } - if err != nil { - runtime.Logger().Error("Expected 'key does not exist' but got a different error", - "error", err, "secretKey", cfg.SecretKey) - return "", fmt.Errorf("expected 'key does not exist' for key=%s, but got: %w", cfg.SecretKey, err) + phases := cfg.EffectivePhases() + if len(phases) == 0 { + return "", fmt.Errorf("no vault workflow phases configured") + } + + var lastErr error + for _, phase := range phases { + if err := evaluatePhase(runtime, phase); err != nil { + lastErr = err + runtime.Logger().Warn("Vault secret workflow phase not yet satisfied", + "phaseName", phase.Name, + "error", err, + ) + continue } - runtime.Logger().Error("Expected secret to be gone but retrieval succeeded", "secretKey", cfg.SecretKey) - return "", fmt.Errorf("expected secret key=%s to be deleted, but it was still found", cfg.SecretKey) + + runtime.Logger().Info(fmt.Sprintf("Vault secret workflow phase completed: %s", phase.Name), + "phaseName", phase.Name, + "checkCount", len(phase.Checks), + ) + return fmt.Sprintf("Validated phase %s", phase.Name), nil } - if err != nil { - runtime.Logger().Error("Failed to get secret via workflow", "error", err) - return "", fmt.Errorf("failed to get secret: %w", err) + return "", fmt.Errorf("no vault workflow phase matched current state: %w", lastErr) +} + +func evaluatePhase(runtime cre.Runtime, phase config.Phase) error { + if len(phase.Checks) == 0 { + return fmt.Errorf("phase %s has no checks", phase.Name) } - if secret.Value == "" { - runtime.Logger().Error("Secret value is empty") - return "", fmt.Errorf("secret value is empty for key=%s namespace=%s", cfg.SecretKey, cfg.SecretNamespace) + for _, check := range phase.Checks { + runtime.Logger().Info("Vault secret workflow triggered", + "phaseName", phase.Name, + "checkName", check.Name, + "secretKey", check.SecretKey, + "secretNamespace", check.SecretNamespace, + "expectNotFound", check.ExpectNotFound, + ) + + secret, err := runtime.GetSecret(&cre.SecretRequest{ + Namespace: check.SecretNamespace, + Id: check.SecretKey, + }).Await() + + if check.ExpectNotFound { + if err != nil && strings.Contains(err.Error(), "key does not exist") { + runtime.Logger().Info("Vault secret correctly not found after deletion", + "phaseName", phase.Name, + "checkName", check.Name, + "secretKey", check.SecretKey, + ) + continue + } + if err != nil { + return fmt.Errorf("phase %s check %s expected not found for key=%s but got: %w", phase.Name, check.Name, check.SecretKey, err) + } + return fmt.Errorf("phase %s check %s expected deleted secret key=%s, but it was still found", phase.Name, check.Name, check.SecretKey) + } + + if err != nil { + return fmt.Errorf("phase %s check %s failed to get secret: %w", phase.Name, check.Name, err) + } + + if secret.Value == "" { + return fmt.Errorf("phase %s check %s secret value is empty for key=%s namespace=%s", phase.Name, check.Name, check.SecretKey, check.SecretNamespace) + } + + if check.ExpectedValue != "" && secret.Value != check.ExpectedValue { + return fmt.Errorf("phase %s check %s secret value mismatch for key=%s namespace=%s", phase.Name, check.Name, check.SecretKey, check.SecretNamespace) + } + + runtime.Logger().Info("Vault secret retrieved successfully via workflow", + "phaseName", phase.Name, + "checkName", check.Name, + "secretKey", check.SecretKey, + ) } - runtime.Logger().Info("Vault secret retrieved successfully via workflow", "secretKey", cfg.SecretKey) - return fmt.Sprintf("Secret retrieved: key=%s", cfg.SecretKey), nil + return nil } diff --git a/system-tests/tests/test-helpers/before_suite.go b/system-tests/tests/test-helpers/before_suite.go index 8c83f676d7e..23197f4dc8e 100644 --- a/system-tests/tests/test-helpers/before_suite.go +++ b/system-tests/tests/test-helpers/before_suite.go @@ -40,6 +40,7 @@ import ( "github.com/smartcontractkit/chainlink/system-tests/lib/cre/environment/blockchains" "github.com/smartcontractkit/chainlink/system-tests/lib/cre/environment/blockchains/evm" envconfig "github.com/smartcontractkit/chainlink/system-tests/lib/cre/environment/config" + crevault "github.com/smartcontractkit/chainlink/system-tests/lib/cre/vault" crecrypto "github.com/smartcontractkit/chainlink/system-tests/lib/crypto" ttypes "github.com/smartcontractkit/chainlink/system-tests/tests/test-helpers/configuration" @@ -128,6 +129,8 @@ func getOrCreateSharedEnvironment(t *testing.T, tconf *ttypes.TestConfig, flags sharedEnvMu.Unlock() entry.once.Do(func() { + _, err := crevault.EnsureSharedTestLinkingServiceStarted() + require.NoError(t, err, "failed to ensure linking service is running") createEnvironment(t, tconf, flags...) require.NoError(t, chiprouter.EnsureStarted(t.Context()), "failed to ensure chip ingress router is running") in := getEnvironmentConfig(t)