Skip to content

Commit

Permalink
Propagate human errors from webhooks
Browse files Browse the repository at this point in the history
This commit adds a new field error in the webhook response that allows
to propagate errors to the client. With ACME, webhook errors are as
a new subproblem.
  • Loading branch information
maraino committed Nov 15, 2024
1 parent 2f7690c commit 05295d9
Show file tree
Hide file tree
Showing 7 changed files with 200 additions and 2 deletions.
13 changes: 13 additions & 0 deletions acme/order.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"crypto/x509"
"encoding/asn1"
"encoding/json"
"errors"
"fmt"
"net"
"net/url"
Expand All @@ -19,6 +20,7 @@ import (

"github.com/smallstep/certificates/acme/wire"
"github.com/smallstep/certificates/authority/provisioner"
"github.com/smallstep/certificates/webhook"
)

type IdentifierType string
Expand Down Expand Up @@ -304,6 +306,17 @@ func (o *Order) Finalize(ctx context.Context, db DB, csr *x509.CertificateReques
NotAfter: provisioner.NewTimeDuration(o.NotAfter),
}, signOps...)
if err != nil {
// Add subproblem for webhook errors, others can be added later.
var webhookErr *webhook.Error
if errors.As(err, &webhookErr) {
acmeError := NewDetailedError(ErrorUnauthorizedType, webhookErr.Error())

Check failure on line 312 in acme/order.go

View workflow job for this annotation

GitHub Actions / ci / lint / lint

printf: non-constant format string in call to github.com/smallstep/certificates/acme.NewDetailedError (govet)
acmeError.AddSubproblems(Subproblem{
Type: fmt.Sprintf("urn:smallstep:webhook:error:%s", webhookErr.Code),
Detail: webhookErr.Message,
})
return acmeError
}

return WrapErrorISE(err, "error signing certificate for order %s", o.ID)
}

Expand Down
52 changes: 52 additions & 0 deletions acme/order_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import (
"github.com/smallstep/assert"
"github.com/smallstep/certificates/authority"
"github.com/smallstep/certificates/authority/provisioner"
"github.com/smallstep/certificates/errs"
"github.com/smallstep/certificates/webhook"
"go.step.sm/crypto/keyutil"
"go.step.sm/crypto/x509util"
)
Expand Down Expand Up @@ -590,6 +592,55 @@ func TestOrder_Finalize(t *testing.T) {
err: NewErrorISE("error signing certificate for order oID: force"),
}
},
"fail/webhook-error": func(t *testing.T) test {
now := clock.Now()
o := &Order{
ID: "oID",
AccountID: "accID",
Status: StatusReady,
ExpiresAt: now.Add(5 * time.Minute),
AuthorizationIDs: []string{"a", "b"},
Identifiers: []Identifier{
{Type: "dns", Value: "foo.internal"},
{Type: "dns", Value: "bar.internal"},
},
}
csr := &x509.CertificateRequest{
Subject: pkix.Name{
CommonName: "foo.internal",
},
DNSNames: []string{"bar.internal"},
}

return test{
o: o,
csr: csr,
prov: &MockProvisioner{
MauthorizeSign: func(ctx context.Context, token string) ([]provisioner.SignOption, error) {
assert.Equals(t, token, "")
return nil, nil
},
MgetOptions: func() *provisioner.Options {
return nil
},
},
ca: &mockSignAuth{
signWithContext: func(_ context.Context, _csr *x509.CertificateRequest, signOpts provisioner.SignOptions, extraOpts ...provisioner.SignOption) ([]*x509.Certificate, error) {
assert.Equals(t, _csr, csr)
return nil, errs.ForbiddenErr(&webhook.Error{Code: "theCode", Message: "The message"}, "forbidden error")
},
},
db: &MockDB{
MockGetAuthorization: func(ctx context.Context, id string) (*Authorization, error) {
return &Authorization{ID: id, Status: StatusValid}, nil
},
},
err: NewDetailedError(ErrorUnauthorizedType, "The message (theCode)").AddSubproblems(Subproblem{
Type: "urn:smallstep:webhook:error:theCode",
Detail: "The message",
}),
}
},
"fail/error-db.CreateCertificate": func(t *testing.T) test {
now := clock.Now()
o := &Order{
Expand Down Expand Up @@ -1217,6 +1268,7 @@ func TestOrder_Finalize(t *testing.T) {
assert.Equals(t, k.Status, tc.err.Status)
assert.Equals(t, k.Err.Error(), tc.err.Err.Error())
assert.Equals(t, k.Detail, tc.err.Detail)
assert.Equals(t, k.Subproblems, tc.err.Subproblems)
} else {
assert.FatalError(t, errors.New("unexpected error type"))
}
Expand Down
6 changes: 6 additions & 0 deletions authority/provisioner/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ func (wc *WebhookController) Enrich(ctx context.Context, req *webhook.RequestBod
return err
}
if !resp.Allow {
if resp.Error != nil {
return resp.Error
}
return ErrWebhookDenied
}
wc.TemplateData.SetWebhook(wh.Name, resp.Data)
Expand Down Expand Up @@ -101,6 +104,9 @@ func (wc *WebhookController) Authorize(ctx context.Context, req *webhook.Request
return err
}
if !resp.Allow {
if resp.Error != nil {
return resp.Error
}
return ErrWebhookDenied
}
}
Expand Down
50 changes: 50 additions & 0 deletions authority/provisioner/webhook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ func TestWebhookController_Enrich(t *testing.T) {
expectErr bool
expectTemplateData any
assertRequest func(t *testing.T, req *webhook.RequestBody)
assertError func(t *testing.T, err error)
}
tests := map[string]test{
"ok/no enriching webhooks": {
Expand Down Expand Up @@ -228,6 +229,28 @@ func TestWebhookController_Enrich(t *testing.T) {
responses: []*webhook.ResponseBody{{Allow: false}},
expectErr: true,
expectTemplateData: x509util.TemplateData{},
assertError: func(t *testing.T, err error) {
assert.Equal(t, ErrWebhookDenied, err)
},
},
"deny/with error": {
ctl: &WebhookController{
client: http.DefaultClient,
webhooks: []*Webhook{{Name: "people", Kind: "ENRICHING"}},
TemplateData: x509util.TemplateData{},
},
ctx: withRequestID(t, context.Background(), "reqID"),
req: &webhook.RequestBody{},
responses: []*webhook.ResponseBody{{Allow: false, Error: &webhook.Error{
Code: "theCode", Message: "Some message",
}}},
expectErr: true,
expectTemplateData: x509util.TemplateData{},
assertError: func(t *testing.T, err error) {
assert.Equal(t, &webhook.Error{
Code: "theCode", Message: "Some message",
}, err)
},
},
"fail/with options": {
ctl: &WebhookController{
Expand Down Expand Up @@ -268,6 +291,9 @@ func TestWebhookController_Enrich(t *testing.T) {
if test.assertRequest != nil {
test.assertRequest(t, test.req)
}
if test.assertError != nil {
test.assertError(t, err)
}
})
}
}
Expand All @@ -283,6 +309,7 @@ func TestWebhookController_Authorize(t *testing.T) {
responses []*webhook.ResponseBody
expectErr bool
assertRequest func(t *testing.T, req *webhook.RequestBody)
assertError func(t *testing.T, err error)
}
tests := map[string]test{
"ok/no enriching webhooks": {
Expand Down Expand Up @@ -346,6 +373,26 @@ func TestWebhookController_Authorize(t *testing.T) {
req: &webhook.RequestBody{},
responses: []*webhook.ResponseBody{{Allow: false}},
expectErr: true,
assertError: func(t *testing.T, err error) {
assert.Equal(t, ErrWebhookDenied, err)
},
},
"deny/withError": {
ctl: &WebhookController{
client: http.DefaultClient,
webhooks: []*Webhook{{Name: "people", Kind: "AUTHORIZING"}},
},
ctx: withRequestID(t, context.Background(), "reqID"),
req: &webhook.RequestBody{},
responses: []*webhook.ResponseBody{{Allow: false, Error: &webhook.Error{
Code: "theCode", Message: "Some message",
}}},
expectErr: true,
assertError: func(t *testing.T, err error) {
assert.Equal(t, &webhook.Error{
Code: "theCode", Message: "Some message",
}, err)
},
},
"fail/with options": {
ctl: &WebhookController{
Expand Down Expand Up @@ -383,6 +430,9 @@ func TestWebhookController_Authorize(t *testing.T) {
if test.assertRequest != nil {
test.assertRequest(t, test.req)
}
if test.assertError != nil {
test.assertError(t, err)
}
})
}
}
Expand Down
5 changes: 5 additions & 0 deletions errs/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ type ErrorResponse struct {
Message string `json:"message"`
}

// Unwrap implements the Unwrap interface and returns the original error.
func (e *Error) Unwrap() error {
return e.Err
}

// Cause implements the errors.Causer interface and returns the original error.
func (e *Error) Cause() error {
return e.Err
Expand Down
60 changes: 60 additions & 0 deletions errs/errors_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package errs

import (
"errors"
"fmt"
"net/http"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -67,3 +69,61 @@ func TestError_UnmarshalJSON(t *testing.T) {
})
}
}

func TestError_Unwrap(t *testing.T) {
err := errors.New("wrapped error")
tests := []struct {
name string
error error
want string
}{
{"ok New", New(http.StatusBadRequest, "some error"), "some error"},
{"ok New v-wrap", New(http.StatusBadRequest, "some error: %v", err), "some error: wrapped error"},
{"ok NewError", NewError(http.StatusBadRequest, err, "some error"), "some error: wrapped error"},
{"ok NewErr", NewErr(http.StatusBadRequest, err), "wrapped error"},
{"ok NewErr wit message", NewErr(http.StatusBadRequest, err, WithMessage("some message")), "wrapped error"},
{"ok Errorf", Errorf(http.StatusBadRequest, "some error: %w", err), "some error: wrapped error"},
{"ok Errorf v-wrap", Errorf(http.StatusBadRequest, "some error: %v", err), "some error: wrapped error"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := errors.Unwrap(tt.error)
assert.EqualError(t, got, tt.want)
})
}
}

type customError struct {
Message string
}

func (e *customError) Error() string {
return e.Message
}

func TestError_Unwrap_As(t *testing.T) {
err := &customError{Message: "wrapped error"}

tests := []struct {
name string
error error
want bool
wantErr *customError
}{
{"ok NewError", NewError(http.StatusBadRequest, err, "some error"), true, err},
{"ok NewErr", NewErr(http.StatusBadRequest, err), true, err},
{"ok NewErr wit message", NewErr(http.StatusBadRequest, err, WithMessage("some message")), true, err},
{"ok Errorf", Errorf(http.StatusBadRequest, "some error: %w", err), true, err},
{"fail New", New(http.StatusBadRequest, "some error"), false, nil},
{"fail New v-wrap", New(http.StatusBadRequest, "some error: %v", err), false, nil},
{"fail Errorf", Errorf(http.StatusBadRequest, "some error"), false, nil},
{"fail Errorf v-wrap", Errorf(http.StatusBadRequest, "some error: %v", err), false, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var cerr *customError
assert.Equal(t, tt.want, errors.As(tt.error, &cerr))
assert.Equal(t, tt.wantErr, cerr)
})
}
}
16 changes: 14 additions & 2 deletions webhook/types.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package webhook

import (
"fmt"
"time"

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

// ResponseBody is the body returned by webhook servers.
type ResponseBody struct {
Data any `json:"data"`
Allow bool `json:"allow"`
Data any `json:"data"`
Allow bool `json:"allow"`
Error *Error `json:"error,omitempty"`
}

// Error provides details explaining why the webhook was not permitted.
type Error struct {
Code string `json:"code"`
Message string `json:"message"`
}

func (e *Error) Error() string {
return fmt.Sprintf("%s (%s)", e.Message, e.Code)
}

// X509CertificateRequest is the certificate request sent to webhook servers for
Expand Down

0 comments on commit 05295d9

Please sign in to comment.