Skip to content

Commit fdaa665

Browse files
authored
Merge pull request #2066 from smallstep/mariano/webhook-errors
2 parents 2f7690c + 05295d9 commit fdaa665

File tree

7 files changed

+200
-2
lines changed

7 files changed

+200
-2
lines changed

acme/order.go

+13
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"crypto/x509"
88
"encoding/asn1"
99
"encoding/json"
10+
"errors"
1011
"fmt"
1112
"net"
1213
"net/url"
@@ -19,6 +20,7 @@ import (
1920

2021
"github.com/smallstep/certificates/acme/wire"
2122
"github.com/smallstep/certificates/authority/provisioner"
23+
"github.com/smallstep/certificates/webhook"
2224
)
2325

2426
type IdentifierType string
@@ -304,6 +306,17 @@ func (o *Order) Finalize(ctx context.Context, db DB, csr *x509.CertificateReques
304306
NotAfter: provisioner.NewTimeDuration(o.NotAfter),
305307
}, signOps...)
306308
if err != nil {
309+
// Add subproblem for webhook errors, others can be added later.
310+
var webhookErr *webhook.Error
311+
if errors.As(err, &webhookErr) {
312+
acmeError := NewDetailedError(ErrorUnauthorizedType, webhookErr.Error())
313+
acmeError.AddSubproblems(Subproblem{
314+
Type: fmt.Sprintf("urn:smallstep:webhook:error:%s", webhookErr.Code),
315+
Detail: webhookErr.Message,
316+
})
317+
return acmeError
318+
}
319+
307320
return WrapErrorISE(err, "error signing certificate for order %s", o.ID)
308321
}
309322

acme/order_test.go

+52
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import (
1818
"github.com/smallstep/assert"
1919
"github.com/smallstep/certificates/authority"
2020
"github.com/smallstep/certificates/authority/provisioner"
21+
"github.com/smallstep/certificates/errs"
22+
"github.com/smallstep/certificates/webhook"
2123
"go.step.sm/crypto/keyutil"
2224
"go.step.sm/crypto/x509util"
2325
)
@@ -590,6 +592,55 @@ func TestOrder_Finalize(t *testing.T) {
590592
err: NewErrorISE("error signing certificate for order oID: force"),
591593
}
592594
},
595+
"fail/webhook-error": func(t *testing.T) test {
596+
now := clock.Now()
597+
o := &Order{
598+
ID: "oID",
599+
AccountID: "accID",
600+
Status: StatusReady,
601+
ExpiresAt: now.Add(5 * time.Minute),
602+
AuthorizationIDs: []string{"a", "b"},
603+
Identifiers: []Identifier{
604+
{Type: "dns", Value: "foo.internal"},
605+
{Type: "dns", Value: "bar.internal"},
606+
},
607+
}
608+
csr := &x509.CertificateRequest{
609+
Subject: pkix.Name{
610+
CommonName: "foo.internal",
611+
},
612+
DNSNames: []string{"bar.internal"},
613+
}
614+
615+
return test{
616+
o: o,
617+
csr: csr,
618+
prov: &MockProvisioner{
619+
MauthorizeSign: func(ctx context.Context, token string) ([]provisioner.SignOption, error) {
620+
assert.Equals(t, token, "")
621+
return nil, nil
622+
},
623+
MgetOptions: func() *provisioner.Options {
624+
return nil
625+
},
626+
},
627+
ca: &mockSignAuth{
628+
signWithContext: func(_ context.Context, _csr *x509.CertificateRequest, signOpts provisioner.SignOptions, extraOpts ...provisioner.SignOption) ([]*x509.Certificate, error) {
629+
assert.Equals(t, _csr, csr)
630+
return nil, errs.ForbiddenErr(&webhook.Error{Code: "theCode", Message: "The message"}, "forbidden error")
631+
},
632+
},
633+
db: &MockDB{
634+
MockGetAuthorization: func(ctx context.Context, id string) (*Authorization, error) {
635+
return &Authorization{ID: id, Status: StatusValid}, nil
636+
},
637+
},
638+
err: NewDetailedError(ErrorUnauthorizedType, "The message (theCode)").AddSubproblems(Subproblem{
639+
Type: "urn:smallstep:webhook:error:theCode",
640+
Detail: "The message",
641+
}),
642+
}
643+
},
593644
"fail/error-db.CreateCertificate": func(t *testing.T) test {
594645
now := clock.Now()
595646
o := &Order{
@@ -1217,6 +1268,7 @@ func TestOrder_Finalize(t *testing.T) {
12171268
assert.Equals(t, k.Status, tc.err.Status)
12181269
assert.Equals(t, k.Err.Error(), tc.err.Err.Error())
12191270
assert.Equals(t, k.Detail, tc.err.Detail)
1271+
assert.Equals(t, k.Subproblems, tc.err.Subproblems)
12201272
} else {
12211273
assert.FatalError(t, errors.New("unexpected error type"))
12221274
}

authority/provisioner/webhook.go

+6
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ func (wc *WebhookController) Enrich(ctx context.Context, req *webhook.RequestBod
6565
return err
6666
}
6767
if !resp.Allow {
68+
if resp.Error != nil {
69+
return resp.Error
70+
}
6871
return ErrWebhookDenied
6972
}
7073
wc.TemplateData.SetWebhook(wh.Name, resp.Data)
@@ -101,6 +104,9 @@ func (wc *WebhookController) Authorize(ctx context.Context, req *webhook.Request
101104
return err
102105
}
103106
if !resp.Allow {
107+
if resp.Error != nil {
108+
return resp.Error
109+
}
104110
return ErrWebhookDenied
105111
}
106112
}

authority/provisioner/webhook_test.go

+50
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ func TestWebhookController_Enrich(t *testing.T) {
122122
expectErr bool
123123
expectTemplateData any
124124
assertRequest func(t *testing.T, req *webhook.RequestBody)
125+
assertError func(t *testing.T, err error)
125126
}
126127
tests := map[string]test{
127128
"ok/no enriching webhooks": {
@@ -228,6 +229,28 @@ func TestWebhookController_Enrich(t *testing.T) {
228229
responses: []*webhook.ResponseBody{{Allow: false}},
229230
expectErr: true,
230231
expectTemplateData: x509util.TemplateData{},
232+
assertError: func(t *testing.T, err error) {
233+
assert.Equal(t, ErrWebhookDenied, err)
234+
},
235+
},
236+
"deny/with error": {
237+
ctl: &WebhookController{
238+
client: http.DefaultClient,
239+
webhooks: []*Webhook{{Name: "people", Kind: "ENRICHING"}},
240+
TemplateData: x509util.TemplateData{},
241+
},
242+
ctx: withRequestID(t, context.Background(), "reqID"),
243+
req: &webhook.RequestBody{},
244+
responses: []*webhook.ResponseBody{{Allow: false, Error: &webhook.Error{
245+
Code: "theCode", Message: "Some message",
246+
}}},
247+
expectErr: true,
248+
expectTemplateData: x509util.TemplateData{},
249+
assertError: func(t *testing.T, err error) {
250+
assert.Equal(t, &webhook.Error{
251+
Code: "theCode", Message: "Some message",
252+
}, err)
253+
},
231254
},
232255
"fail/with options": {
233256
ctl: &WebhookController{
@@ -268,6 +291,9 @@ func TestWebhookController_Enrich(t *testing.T) {
268291
if test.assertRequest != nil {
269292
test.assertRequest(t, test.req)
270293
}
294+
if test.assertError != nil {
295+
test.assertError(t, err)
296+
}
271297
})
272298
}
273299
}
@@ -283,6 +309,7 @@ func TestWebhookController_Authorize(t *testing.T) {
283309
responses []*webhook.ResponseBody
284310
expectErr bool
285311
assertRequest func(t *testing.T, req *webhook.RequestBody)
312+
assertError func(t *testing.T, err error)
286313
}
287314
tests := map[string]test{
288315
"ok/no enriching webhooks": {
@@ -346,6 +373,26 @@ func TestWebhookController_Authorize(t *testing.T) {
346373
req: &webhook.RequestBody{},
347374
responses: []*webhook.ResponseBody{{Allow: false}},
348375
expectErr: true,
376+
assertError: func(t *testing.T, err error) {
377+
assert.Equal(t, ErrWebhookDenied, err)
378+
},
379+
},
380+
"deny/withError": {
381+
ctl: &WebhookController{
382+
client: http.DefaultClient,
383+
webhooks: []*Webhook{{Name: "people", Kind: "AUTHORIZING"}},
384+
},
385+
ctx: withRequestID(t, context.Background(), "reqID"),
386+
req: &webhook.RequestBody{},
387+
responses: []*webhook.ResponseBody{{Allow: false, Error: &webhook.Error{
388+
Code: "theCode", Message: "Some message",
389+
}}},
390+
expectErr: true,
391+
assertError: func(t *testing.T, err error) {
392+
assert.Equal(t, &webhook.Error{
393+
Code: "theCode", Message: "Some message",
394+
}, err)
395+
},
349396
},
350397
"fail/with options": {
351398
ctl: &WebhookController{
@@ -383,6 +430,9 @@ func TestWebhookController_Authorize(t *testing.T) {
383430
if test.assertRequest != nil {
384431
test.assertRequest(t, test.req)
385432
}
433+
if test.assertError != nil {
434+
test.assertError(t, err)
435+
}
386436
})
387437
}
388438
}

errs/error.go

+5
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ type ErrorResponse struct {
6262
Message string `json:"message"`
6363
}
6464

65+
// Unwrap implements the Unwrap interface and returns the original error.
66+
func (e *Error) Unwrap() error {
67+
return e.Err
68+
}
69+
6570
// Cause implements the errors.Causer interface and returns the original error.
6671
func (e *Error) Cause() error {
6772
return e.Err

errs/errors_test.go

+60
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package errs
22

33
import (
4+
"errors"
45
"fmt"
6+
"net/http"
57
"testing"
68

79
"github.com/stretchr/testify/assert"
@@ -67,3 +69,61 @@ func TestError_UnmarshalJSON(t *testing.T) {
6769
})
6870
}
6971
}
72+
73+
func TestError_Unwrap(t *testing.T) {
74+
err := errors.New("wrapped error")
75+
tests := []struct {
76+
name string
77+
error error
78+
want string
79+
}{
80+
{"ok New", New(http.StatusBadRequest, "some error"), "some error"},
81+
{"ok New v-wrap", New(http.StatusBadRequest, "some error: %v", err), "some error: wrapped error"},
82+
{"ok NewError", NewError(http.StatusBadRequest, err, "some error"), "some error: wrapped error"},
83+
{"ok NewErr", NewErr(http.StatusBadRequest, err), "wrapped error"},
84+
{"ok NewErr wit message", NewErr(http.StatusBadRequest, err, WithMessage("some message")), "wrapped error"},
85+
{"ok Errorf", Errorf(http.StatusBadRequest, "some error: %w", err), "some error: wrapped error"},
86+
{"ok Errorf v-wrap", Errorf(http.StatusBadRequest, "some error: %v", err), "some error: wrapped error"},
87+
}
88+
for _, tt := range tests {
89+
t.Run(tt.name, func(t *testing.T) {
90+
got := errors.Unwrap(tt.error)
91+
assert.EqualError(t, got, tt.want)
92+
})
93+
}
94+
}
95+
96+
type customError struct {
97+
Message string
98+
}
99+
100+
func (e *customError) Error() string {
101+
return e.Message
102+
}
103+
104+
func TestError_Unwrap_As(t *testing.T) {
105+
err := &customError{Message: "wrapped error"}
106+
107+
tests := []struct {
108+
name string
109+
error error
110+
want bool
111+
wantErr *customError
112+
}{
113+
{"ok NewError", NewError(http.StatusBadRequest, err, "some error"), true, err},
114+
{"ok NewErr", NewErr(http.StatusBadRequest, err), true, err},
115+
{"ok NewErr wit message", NewErr(http.StatusBadRequest, err, WithMessage("some message")), true, err},
116+
{"ok Errorf", Errorf(http.StatusBadRequest, "some error: %w", err), true, err},
117+
{"fail New", New(http.StatusBadRequest, "some error"), false, nil},
118+
{"fail New v-wrap", New(http.StatusBadRequest, "some error: %v", err), false, nil},
119+
{"fail Errorf", Errorf(http.StatusBadRequest, "some error"), false, nil},
120+
{"fail Errorf v-wrap", Errorf(http.StatusBadRequest, "some error: %v", err), false, nil},
121+
}
122+
for _, tt := range tests {
123+
t.Run(tt.name, func(t *testing.T) {
124+
var cerr *customError
125+
assert.Equal(t, tt.want, errors.As(tt.error, &cerr))
126+
assert.Equal(t, tt.wantErr, cerr)
127+
})
128+
}
129+
}

webhook/types.go

+14-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package webhook
22

33
import (
4+
"fmt"
45
"time"
56

67
"go.step.sm/crypto/sshutil"
@@ -9,8 +10,19 @@ import (
910

1011
// ResponseBody is the body returned by webhook servers.
1112
type ResponseBody struct {
12-
Data any `json:"data"`
13-
Allow bool `json:"allow"`
13+
Data any `json:"data"`
14+
Allow bool `json:"allow"`
15+
Error *Error `json:"error,omitempty"`
16+
}
17+
18+
// Error provides details explaining why the webhook was not permitted.
19+
type Error struct {
20+
Code string `json:"code"`
21+
Message string `json:"message"`
22+
}
23+
24+
func (e *Error) Error() string {
25+
return fmt.Sprintf("%s (%s)", e.Message, e.Code)
1426
}
1527

1628
// X509CertificateRequest is the certificate request sent to webhook servers for

0 commit comments

Comments
 (0)