Skip to content

Commit 45f2fc1

Browse files
vault: add generic authorizer with jwt auth support
1 parent a4e898e commit 45f2fc1

18 files changed

Lines changed: 1874 additions & 491 deletions

.mockery.yaml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ packages:
2727
DonSubscriber:
2828
github.com/smartcontractkit/chainlink/v2/core/capabilities/vault:
2929
interfaces:
30-
RequestAuthorizer:
30+
Authorizer:
3131
github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes:
3232
interfaces:
3333
SecretsService:
@@ -434,4 +434,3 @@ packages:
434434
github.com/smartcontractkit/chainlink/v2/core/services/workflows/metering:
435435
interfaces:
436436
BillingClient:
437-
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package vault
2+
3+
import (
4+
"context"
5+
"encoding/hex"
6+
"encoding/json"
7+
"errors"
8+
"fmt"
9+
"time"
10+
11+
jsonrpc "github.com/smartcontractkit/chainlink-common/pkg/jsonrpc2"
12+
"github.com/smartcontractkit/chainlink-common/pkg/logger"
13+
"github.com/smartcontractkit/chainlink-evm/gethwrappers/workflow/generated/workflow_registry_wrapper_v2"
14+
workflowsyncerv2 "github.com/smartcontractkit/chainlink/v2/core/services/workflows/syncer/v2"
15+
)
16+
17+
const (
18+
allowListBasedAuthRetryCount = 3
19+
allowListBasedAuthRetryInterval = 3 * time.Second
20+
)
21+
22+
type allowListBasedAuth struct {
23+
workflowRegistrySyncer workflowsyncerv2.WorkflowRegistrySyncer
24+
lggr logger.Logger
25+
retryCount int
26+
retryInterval time.Duration
27+
}
28+
29+
// AuthorizeRequest authorizes a request using AllowListBasedAuth.
30+
// It does NOT check if the request method is allowed.
31+
func (r *allowListBasedAuth) AuthorizeRequest(ctx context.Context, req jsonrpc.Request[json.RawMessage]) (*AuthResult, error) {
32+
r.lggr.Debugw("AllowListBasedAuth authorizing request", "method", req.Method, "requestID", req.ID)
33+
requestDigest, err := req.Digest()
34+
if err != nil {
35+
r.lggr.Debugw("AllowListBasedAuth failed to create digest", "method", req.Method, "requestID", req.ID, "error", err)
36+
return nil, err
37+
}
38+
requestDigestBytes, err := hex.DecodeString(requestDigest)
39+
if err != nil {
40+
r.lggr.Debugw("AllowListBasedAuth failed to decode digest", "method", req.Method, "requestID", req.ID, "requestDigest", requestDigest, "error", err)
41+
return nil, err
42+
}
43+
requestDigestBytes32 := [32]byte(requestDigestBytes)
44+
if r.workflowRegistrySyncer == nil {
45+
r.lggr.Errorw("AllowListBasedAuth workflowRegistrySyncer is nil", "method", req.Method, "requestID", req.ID)
46+
return nil, errors.New("internal error: workflowRegistrySyncer is nil")
47+
}
48+
allowlistedRequest, allowedRequestsStrs, err := r.findAllowlistedItemWithRetry(ctx, req, requestDigest, requestDigestBytes32)
49+
if err != nil {
50+
return nil, err
51+
}
52+
if allowlistedRequest == nil {
53+
r.lggr.Debugw("AllowListBasedAuth request digest not allowlisted",
54+
"method", req.Method,
55+
"requestID", req.ID,
56+
"digestHexStr", requestDigest,
57+
"allowedRequestsStrs", allowedRequestsStrs)
58+
return nil, errors.New("request not allowlisted")
59+
}
60+
61+
if time.Now().UTC().Unix() > int64(allowlistedRequest.ExpiryTimestamp) {
62+
authorizedRequestStr := string(allowlistedRequest.RequestDigest[:])
63+
r.lggr.Debugw("AllowListBasedAuth authorization expired", "method", req.Method, "requestID", req.ID, "authorizedRequestStr", authorizedRequestStr, "expiryTimestamp", allowlistedRequest.ExpiryTimestamp)
64+
return nil, errors.New("request authorization expired")
65+
}
66+
67+
digestKey := string(allowlistedRequest.RequestDigest[:])
68+
r.lggr.Debugw("AllowListBasedAuth authorization succeeded", "method", req.Method, "requestID", req.ID, "authorizedRequestStr", digestKey, "owner", allowlistedRequest.Owner.Hex(), "expiryTimestamp", allowlistedRequest.ExpiryTimestamp)
69+
return &AuthResult{
70+
workflowOwner: allowlistedRequest.Owner.Hex(),
71+
digest: digestKey,
72+
expiresAt: int64(allowlistedRequest.ExpiryTimestamp),
73+
}, nil
74+
}
75+
76+
func (r *allowListBasedAuth) findAllowlistedItemWithRetry(ctx context.Context, req jsonrpc.Request[json.RawMessage], requestDigest string, requestDigestBytes32 [32]byte) (*workflow_registry_wrapper_v2.WorkflowRegistryOwnerAllowlistedRequest, []string, error) {
77+
for attempt := 0; attempt <= r.retryCount; attempt++ {
78+
allowedRequests := r.workflowRegistrySyncer.GetAllowlistedRequests(ctx)
79+
allowedRequestsStrs := make([]string, 0, len(allowedRequests))
80+
for _, rr := range allowedRequests {
81+
allowedReqStr := fmt.Sprintf("AuthorizedOwner: %s, RequestDigest: %s, ExpiryTimestamp: %d", rr.Owner.Hex(), hex.EncodeToString(rr.RequestDigest[:]), rr.ExpiryTimestamp)
82+
allowedRequestsStrs = append(allowedRequestsStrs, allowedReqStr)
83+
}
84+
r.lggr.Debugw("AllowListBasedAuth loaded allowlisted requests", "method", req.Method, "requestID", req.ID, "attempt", attempt+1, "allowedRequests", allowedRequestsStrs)
85+
86+
allowlistedRequest := r.fetchAllowlistedItem(allowedRequests, requestDigestBytes32)
87+
if allowlistedRequest != nil {
88+
return allowlistedRequest, allowedRequestsStrs, nil
89+
}
90+
if attempt == r.retryCount {
91+
return nil, allowedRequestsStrs, nil
92+
}
93+
94+
r.lggr.Debugw("AllowListBasedAuth request digest not yet allowlisted, retrying",
95+
"method", req.Method,
96+
"requestID", req.ID,
97+
"digestHexStr", requestDigest,
98+
"attempt", attempt+1,
99+
"maxAttempts", r.retryCount+1,
100+
"retryInterval", r.retryInterval)
101+
if err := sleepWithContext(ctx, r.retryInterval); err != nil {
102+
r.lggr.Debugw("AllowListBasedAuth retry canceled", "method", req.Method, "requestID", req.ID, "error", err)
103+
return nil, nil, err
104+
}
105+
}
106+
107+
return nil, nil, nil // unreachable: loop always returns
108+
}
109+
110+
func (r *allowListBasedAuth) fetchAllowlistedItem(allowListedRequests []workflow_registry_wrapper_v2.WorkflowRegistryOwnerAllowlistedRequest, digest [32]byte) *workflow_registry_wrapper_v2.WorkflowRegistryOwnerAllowlistedRequest {
111+
for _, item := range allowListedRequests {
112+
if item.RequestDigest == digest {
113+
return &item
114+
}
115+
}
116+
return nil
117+
}
118+
119+
// NewAllowListBasedAuth creates the allowlist-backed Vault auth mechanism.
120+
func NewAllowListBasedAuth(lggr logger.Logger, workflowRegistrySyncer workflowsyncerv2.WorkflowRegistrySyncer) *allowListBasedAuth {
121+
return &allowListBasedAuth{
122+
workflowRegistrySyncer: workflowRegistrySyncer,
123+
lggr: logger.Named(lggr, "VaultAllowListBasedAuth"),
124+
retryCount: allowListBasedAuthRetryCount,
125+
retryInterval: allowListBasedAuthRetryInterval,
126+
}
127+
}
128+
129+
func sleepWithContext(ctx context.Context, d time.Duration) error {
130+
timer := time.NewTimer(d)
131+
defer timer.Stop()
132+
133+
select {
134+
case <-ctx.Done():
135+
return ctx.Err()
136+
case <-timer.C:
137+
return nil
138+
}
139+
}

core/capabilities/vault/request_authorizer_test.go renamed to core/capabilities/vault/allow_list_based_auth_test.go

Lines changed: 44 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import (
1919
syncerv2mocks "github.com/smartcontractkit/chainlink/v2/core/services/workflows/syncer/v2/mocks"
2020
)
2121

22-
func TestRequestAuthorizer_CreateSecrets(t *testing.T) {
22+
func TestAllowListBasedAuth_CreateSecrets(t *testing.T) {
2323
params, err := json.Marshal(vaultcommon.CreateSecretsRequest{
2424
EncryptedSecrets: []*vaultcommon.EncryptedSecret{
2525
{
@@ -59,7 +59,7 @@ func TestRequestAuthorizer_CreateSecrets(t *testing.T) {
5959
testAuthForRequests(t, allowListedReq, notAllowListedReq)
6060
}
6161

62-
func TestRequestAuthorizer_UpdateSecrets(t *testing.T) {
62+
func TestAllowListBasedAuth_UpdateSecrets(t *testing.T) {
6363
params, err := json.Marshal(vaultcommon.UpdateSecretsRequest{
6464
EncryptedSecrets: []*vaultcommon.EncryptedSecret{
6565
{
@@ -98,7 +98,7 @@ func TestRequestAuthorizer_UpdateSecrets(t *testing.T) {
9898
testAuthForRequests(t, allowListedReq, notAllowListedReq)
9999
}
100100

101-
func TestRequestAuthorizer_DeleteSecrets(t *testing.T) {
101+
func TestAllowListBasedAuth_DeleteSecrets(t *testing.T) {
102102
params, err := json.Marshal(vaultcommon.DeleteSecretsRequest{
103103
Ids: []*vaultcommon.SecretIdentifier{
104104
{
@@ -131,7 +131,7 @@ func TestRequestAuthorizer_DeleteSecrets(t *testing.T) {
131131
testAuthForRequests(t, allowListedReq, notAllowListedReq)
132132
}
133133

134-
func TestRequestAuthorizer_ListSecrets(t *testing.T) {
134+
func TestAllowListBasedAuth_ListSecrets(t *testing.T) {
135135
params, err := json.Marshal(vaultcommon.ListSecretIdentifiersRequest{
136136
Namespace: "b",
137137
})
@@ -159,14 +159,16 @@ func testAuthForRequests(t *testing.T, allowlistedRequest, notAllowlistedRequest
159159
owner := common.Address{1, 2, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
160160

161161
mockSyncer := syncerv2mocks.NewWorkflowRegistrySyncer(t)
162-
auth := NewRequestAuthorizer(lggr, mockSyncer)
162+
auth := NewAllowListBasedAuth(lggr, mockSyncer)
163+
auth.retryCount = 0
164+
auth.retryInterval = time.Millisecond
163165

164166
// Happy path
165167
digest, err := allowlistedRequest.Digest()
166168
require.NoError(t, err)
167169
digestBytes, err := hex.DecodeString(digest)
168170
require.NoError(t, err)
169-
expiry := uint64(time.Now().UTC().Unix() + 100) //nolint:gosec // it is a safe conversion
171+
expiry := time.Now().UTC().Unix() + 100
170172
allowlisted := []workflow_registry_wrapper_v2.WorkflowRegistryOwnerAllowlistedRequest{
171173
{
172174
RequestDigest: [32]byte(digestBytes),
@@ -175,15 +177,16 @@ func testAuthForRequests(t *testing.T, allowlistedRequest, notAllowlistedRequest
175177
},
176178
}
177179
mockSyncer.On("GetAllowlistedRequests", mock.Anything).Return(allowlisted)
178-
isAuthorized, gotOwner, err := auth.AuthorizeRequest(t.Context(), allowlistedRequest)
179-
require.True(t, isAuthorized, err)
180-
require.Equal(t, owner.Hex(), gotOwner)
180+
authResult, err := auth.AuthorizeRequest(t.Context(), allowlistedRequest)
181181
require.NoError(t, err)
182+
require.Equal(t, owner.Hex(), authResult.AuthorizedOwner())
183+
require.Equal(t, expiry, authResult.GetExpiresAt())
184+
require.NotEmpty(t, authResult.GetDigest())
182185

183-
// Already authorized
184-
isAuthorized, _, err = auth.AuthorizeRequest(t.Context(), allowlistedRequest)
185-
require.False(t, isAuthorized)
186-
require.ErrorContains(t, err, "already authorized previously")
186+
// Same request is still authorized here; replay protection lives in the generic Authorizer.
187+
authResult, err = auth.AuthorizeRequest(t.Context(), allowlistedRequest)
188+
require.NoError(t, err)
189+
require.Equal(t, owner.Hex(), authResult.AuthorizedOwner())
187190

188191
// Expired request
189192
allowlistedReqCopy := allowlistedRequest
@@ -195,16 +198,16 @@ func testAuthForRequests(t *testing.T, allowlistedRequest, notAllowlistedRequest
195198
allowlisted[0].RequestDigest = [32]byte(allowlistedReqCopyDigestBytes)
196199
allowlisted[0].ExpiryTimestamp = uint32(time.Now().UTC().Unix() - 1) //nolint:gosec // it is a safe conversion
197200
mockSyncer.On("GetAllowlistedRequests", mock.Anything).Return(allowlisted)
198-
isAuthorized, _, err = auth.AuthorizeRequest(t.Context(), allowlistedReqCopy)
199-
require.False(t, isAuthorized)
201+
authResult, err = auth.AuthorizeRequest(t.Context(), allowlistedReqCopy)
202+
require.Nil(t, authResult)
200203
require.ErrorContains(t, err, "authorization expired")
201204

202-
isAuthorized, _, err = auth.AuthorizeRequest(t.Context(), notAllowlistedRequest)
203-
require.False(t, isAuthorized)
205+
authResult, err = auth.AuthorizeRequest(t.Context(), notAllowlistedRequest)
206+
require.Nil(t, authResult)
204207
require.ErrorContains(t, err, "not allowlisted")
205208
}
206209

207-
func TestRequestAuthorizer_RetriesAllowlistReadsUntilDigestAppears(t *testing.T) {
210+
func TestAllowListBasedAuth_RetriesUntilRequestIsAllowlisted(t *testing.T) {
208211
lggr := logger.TestLogger(t)
209212
owner := common.Address{1, 2, 3}
210213
req := makeListSecretsRequest(t, "123", "b")
@@ -213,55 +216,47 @@ func TestRequestAuthorizer_RetriesAllowlistReadsUntilDigestAppears(t *testing.T)
213216
require.NoError(t, err)
214217
digestBytes, err := hex.DecodeString(digest)
215218
require.NoError(t, err)
216-
219+
expiry := time.Now().UTC().Unix() + 100
217220
allowlisted := []workflow_registry_wrapper_v2.WorkflowRegistryOwnerAllowlistedRequest{
218221
{
219222
RequestDigest: [32]byte(digestBytes),
220223
Owner: owner,
221-
ExpiryTimestamp: uint32(time.Now().UTC().Unix() + 100), //nolint:gosec // test fixture expiry is bounded and safe here
224+
ExpiryTimestamp: uint32(expiry), //nolint:gosec // it is a safe conversion
222225
},
223226
}
224227

225228
mockSyncer := syncerv2mocks.NewWorkflowRegistrySyncer(t)
229+
auth := NewAllowListBasedAuth(lggr, mockSyncer)
230+
auth.retryCount = 2
231+
auth.retryInterval = time.Millisecond
232+
226233
mockSyncer.On("GetAllowlistedRequests", mock.Anything).Return([]workflow_registry_wrapper_v2.WorkflowRegistryOwnerAllowlistedRequest{}).Once()
227234
mockSyncer.On("GetAllowlistedRequests", mock.Anything).Return([]workflow_registry_wrapper_v2.WorkflowRegistryOwnerAllowlistedRequest{}).Once()
228235
mockSyncer.On("GetAllowlistedRequests", mock.Anything).Return(allowlisted).Once()
229236

230-
auth := NewRequestAuthorizer(lggr, mockSyncer)
231-
sleepCalls := 0
232-
auth.sleep = func(d time.Duration) {
233-
require.Equal(t, allowlistReadRetryInterval, d)
234-
sleepCalls++
235-
}
236-
237-
isAuthorized, gotOwner, err := auth.AuthorizeRequest(t.Context(), req)
238-
require.True(t, isAuthorized, err)
237+
authResult, err := auth.AuthorizeRequest(t.Context(), req)
239238
require.NoError(t, err)
240-
require.Equal(t, owner.Hex(), gotOwner)
241-
require.Equal(t, 2, sleepCalls)
239+
require.Equal(t, owner.Hex(), authResult.AuthorizedOwner())
240+
require.Equal(t, expiry, authResult.GetExpiresAt())
242241
}
243242

244-
func TestRequestAuthorizer_FailsAfterAllowlistReadRetries(t *testing.T) {
243+
func TestAllowListBasedAuth_FailsAfterAllowlistReadRetries(t *testing.T) {
245244
lggr := logger.TestLogger(t)
246245
req := makeListSecretsRequest(t, "123", "b")
247246

248247
mockSyncer := syncerv2mocks.NewWorkflowRegistrySyncer(t)
249-
mockSyncer.On("GetAllowlistedRequests", mock.Anything).Return([]workflow_registry_wrapper_v2.WorkflowRegistryOwnerAllowlistedRequest{}).Times(allowlistReadRetryCount + 1)
248+
mockSyncer.On("GetAllowlistedRequests", mock.Anything).Return([]workflow_registry_wrapper_v2.WorkflowRegistryOwnerAllowlistedRequest{}).Times(3)
250249

251-
auth := NewRequestAuthorizer(lggr, mockSyncer)
252-
sleepCalls := 0
253-
auth.sleep = func(d time.Duration) {
254-
require.Equal(t, allowlistReadRetryInterval, d)
255-
sleepCalls++
256-
}
250+
auth := NewAllowListBasedAuth(lggr, mockSyncer)
251+
auth.retryCount = 2
252+
auth.retryInterval = time.Millisecond
257253

258-
isAuthorized, _, err := auth.AuthorizeRequest(t.Context(), req)
259-
require.False(t, isAuthorized)
254+
authResult, err := auth.AuthorizeRequest(t.Context(), req)
255+
require.Nil(t, authResult)
260256
require.ErrorContains(t, err, "not allowlisted")
261-
require.Equal(t, allowlistReadRetryCount, sleepCalls)
262257
}
263258

264-
func TestRequestAuthorizer_StopsRetriesWhenContextCanceled(t *testing.T) {
259+
func TestAllowListBasedAuth_StopsRetriesWhenContextCanceled(t *testing.T) {
265260
lggr := logger.TestLogger(t)
266261
req := makeListSecretsRequest(t, "123", "b")
267262

@@ -271,16 +266,13 @@ func TestRequestAuthorizer_StopsRetriesWhenContextCanceled(t *testing.T) {
271266
mockSyncer := syncerv2mocks.NewWorkflowRegistrySyncer(t)
272267
mockSyncer.On("GetAllowlistedRequests", mock.Anything).Return([]workflow_registry_wrapper_v2.WorkflowRegistryOwnerAllowlistedRequest{}).Once()
273268

274-
auth := NewRequestAuthorizer(lggr, mockSyncer)
275-
sleepCalls := 0
276-
auth.sleep = func(time.Duration) {
277-
sleepCalls++
278-
}
269+
auth := NewAllowListBasedAuth(lggr, mockSyncer)
270+
auth.retryCount = 2
271+
auth.retryInterval = time.Second
279272

280-
isAuthorized, _, err := auth.AuthorizeRequest(ctx, req)
281-
require.False(t, isAuthorized)
282-
require.ErrorContains(t, err, "not allowlisted")
283-
require.Zero(t, sleepCalls)
273+
authResult, err := auth.AuthorizeRequest(ctx, req)
274+
require.Nil(t, authResult)
275+
require.ErrorIs(t, err, context.Canceled)
284276
}
285277

286278
func makeListSecretsRequest(t *testing.T, id, namespace string) jsonrpc.Request[json.RawMessage] {

0 commit comments

Comments
 (0)