From 7101fbb0ee939d24756695508845e78e41a1cb59 Mon Sep 17 00:00:00 2001 From: Andrew Reed Date: Thu, 29 Sep 2022 19:16:26 -0500 Subject: [PATCH] Provisioner webhooks (#1001) --- CHANGELOG.md | 1 + acme/order.go | 8 + authority/admin/api/handler.go | 102 +-- authority/admin/api/webhook.go | 235 ++++++ authority/admin/api/webhook_test.go | 668 ++++++++++++++++++ authority/admin/db/nosql/provisioner.go | 88 +++ authority/admin/db/nosql/provisioner_test.go | 189 +++++ authority/authority.go | 2 + authority/authorize_test.go | 4 +- authority/options.go | 9 + authority/provisioner/acme.go | 2 + authority/provisioner/acme_test.go | 4 +- authority/provisioner/aws.go | 4 + authority/provisioner/aws_test.go | 12 +- authority/provisioner/azure.go | 4 + authority/provisioner/azure_test.go | 12 +- authority/provisioner/controller.go | 17 + authority/provisioner/gcp.go | 4 + authority/provisioner/gcp_test.go | 8 +- authority/provisioner/jwk.go | 4 + authority/provisioner/jwk_test.go | 3 +- authority/provisioner/k8sSA.go | 4 + authority/provisioner/k8sSA_test.go | 8 +- authority/provisioner/nebula.go | 4 + authority/provisioner/oidc.go | 5 + authority/provisioner/oidc_test.go | 4 +- authority/provisioner/options.go | 11 + authority/provisioner/options_test.go | 30 + authority/provisioner/provisioner.go | 3 + authority/provisioner/scep.go | 2 + authority/provisioner/ssh_test.go | 2 + authority/provisioner/testdata/certs/foo.crt | 14 + .../provisioner/testdata/secrets/foo.key | 5 + authority/provisioner/webhook.go | 209 ++++++ authority/provisioner/webhook_test.go | 473 +++++++++++++ authority/provisioner/x5c.go | 4 + authority/provisioner/x5c_test.go | 10 +- authority/provisioners.go | 100 ++- authority/provisioners_test.go | 80 +++ authority/ssh.go | 55 ++ authority/ssh_test.go | 14 + authority/tls.go | 77 +- authority/tls_test.go | 72 ++ authority/webhook.go | 8 + authority/webhook_test.go | 27 + ca/adminClient.go | 97 +++ ca/ca.go | 57 +- go.mod | 9 +- go.sum | 16 +- scep/authority.go | 8 + webhook/options.go | 97 +++ webhook/options_test.go | 116 +++ webhook/types.go | 71 ++ 53 files changed, 2960 insertions(+), 112 deletions(-) create mode 100644 authority/admin/api/webhook.go create mode 100644 authority/admin/api/webhook_test.go create mode 100644 authority/provisioner/testdata/certs/foo.crt create mode 100644 authority/provisioner/testdata/secrets/foo.key create mode 100644 authority/provisioner/webhook.go create mode 100644 authority/provisioner/webhook_test.go create mode 100644 authority/webhook.go create mode 100644 authority/webhook_test.go create mode 100644 webhook/options.go create mode 100644 webhook/options_test.go create mode 100644 webhook/types.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d2f50379..5ac4209b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Added support for ACME device-attest-01 challenge. - Added name constraints evaluation and enforcement when issuing or renewing X.509 certificates. +- Added provisioner webhooks for augmenting template data and authorizing certificate requests before signing. ## [0.22.1] - 2022-08-31 ### Fixed diff --git a/acme/order.go b/acme/order.go index 96c925f1..7748df22 100644 --- a/acme/order.go +++ b/acme/order.go @@ -194,6 +194,14 @@ func (o *Order) Finalize(ctx context.Context, db DB, csr *x509.CertificateReques if err != nil { return WrapErrorISE(err, "error retrieving authorization options from ACME provisioner") } + // Unlike most of the provisioners, ACME's AuthorizeSign method doesn't + // define the templates, and the template data used in WebHooks is not + // available. + for _, signOp := range signOps { + if wc, ok := signOp.(*provisioner.WebhookController); ok { + wc.TemplateData = data + } + } templateOptions, err := provisioner.CustomTemplateOptions(p.GetOptions(), data, defaultTemplate) if err != nil { diff --git a/authority/admin/api/handler.go b/authority/admin/api/handler.go index 1e5919ce..a4faf936 100644 --- a/authority/admin/api/handler.go +++ b/authority/admin/api/handler.go @@ -4,41 +4,47 @@ import ( "context" "net/http" - "github.com/smallstep/certificates/acme" "github.com/smallstep/certificates/api" "github.com/smallstep/certificates/authority" - "github.com/smallstep/certificates/authority/admin" ) -// Handler is the Admin API request handler. -type Handler struct { - acmeResponder ACMEAdminResponder - policyResponder PolicyAdminResponder +var mustAuthority = func(ctx context.Context) adminAuthority { + return authority.MustFromContext(ctx) } -// Route traffic and implement the Router interface. -// -// Deprecated: use Route(r api.Router, acmeResponder ACMEAdminResponder, policyResponder PolicyAdminResponder) -func (h *Handler) Route(r api.Router) { - Route(r, h.acmeResponder, h.policyResponder) +type router struct { + acmeResponder ACMEAdminResponder + policyResponder PolicyAdminResponder + webhookResponder WebhookAdminResponder } -// NewHandler returns a new Authority Config Handler. -// -// Deprecated: use Route(r api.Router, acmeResponder ACMEAdminResponder, policyResponder PolicyAdminResponder) -func NewHandler(auth adminAuthority, adminDB admin.DB, acmeDB acme.DB, acmeResponder ACMEAdminResponder, policyResponder PolicyAdminResponder) api.RouterHandler { - return &Handler{ - acmeResponder: acmeResponder, - policyResponder: policyResponder, +type RouterOption func(*router) + +func WithACMEResponder(acmeResponder ACMEAdminResponder) RouterOption { + return func(r *router) { + r.acmeResponder = acmeResponder } } -var mustAuthority = func(ctx context.Context) adminAuthority { - return authority.MustFromContext(ctx) +func WithPolicyResponder(policyResponder PolicyAdminResponder) RouterOption { + return func(r *router) { + r.policyResponder = policyResponder + } +} + +func WithWebhookResponder(webhookResponder WebhookAdminResponder) RouterOption { + return func(r *router) { + r.webhookResponder = webhookResponder + } } // Route traffic and implement the Router interface. -func Route(r api.Router, acmeResponder ACMEAdminResponder, policyResponder PolicyAdminResponder) { +func Route(r api.Router, options ...RouterOption) { + router := &router{} + for _, fn := range options { + fn(router) + } + authnz := func(next http.HandlerFunc) http.HandlerFunc { return extractAuthorizeTokenAdmin(requireAPIEnabled(next)) } @@ -67,6 +73,10 @@ func Route(r api.Router, acmeResponder ACMEAdminResponder, policyResponder Polic return authnz(disabledInStandalone(loadProvisionerByName(requireEABEnabled(loadExternalAccountKey(next))))) } + webhookMiddleware := func(next http.HandlerFunc) http.HandlerFunc { + return authnz(loadProvisionerByName(next)) + } + // Provisioners r.MethodFunc("GET", "/provisioners/{name}", authnz(GetProvisioner)) r.MethodFunc("GET", "/provisioners", authnz(GetProvisioners)) @@ -82,36 +92,42 @@ func Route(r api.Router, acmeResponder ACMEAdminResponder, policyResponder Polic r.MethodFunc("DELETE", "/admins/{id}", authnz(DeleteAdmin)) // ACME responder - if acmeResponder != nil { + if router.acmeResponder != nil { // ACME External Account Binding Keys - r.MethodFunc("GET", "/acme/eab/{provisionerName}/{reference}", acmeEABMiddleware(acmeResponder.GetExternalAccountKeys)) - r.MethodFunc("GET", "/acme/eab/{provisionerName}", acmeEABMiddleware(acmeResponder.GetExternalAccountKeys)) - r.MethodFunc("POST", "/acme/eab/{provisionerName}", acmeEABMiddleware(acmeResponder.CreateExternalAccountKey)) - r.MethodFunc("DELETE", "/acme/eab/{provisionerName}/{id}", acmeEABMiddleware(acmeResponder.DeleteExternalAccountKey)) + r.MethodFunc("GET", "/acme/eab/{provisionerName}/{reference}", acmeEABMiddleware(router.acmeResponder.GetExternalAccountKeys)) + r.MethodFunc("GET", "/acme/eab/{provisionerName}", acmeEABMiddleware(router.acmeResponder.GetExternalAccountKeys)) + r.MethodFunc("POST", "/acme/eab/{provisionerName}", acmeEABMiddleware(router.acmeResponder.CreateExternalAccountKey)) + r.MethodFunc("DELETE", "/acme/eab/{provisionerName}/{id}", acmeEABMiddleware(router.acmeResponder.DeleteExternalAccountKey)) } // Policy responder - if policyResponder != nil { + if router.policyResponder != nil { // Policy - Authority - r.MethodFunc("GET", "/policy", authorityPolicyMiddleware(policyResponder.GetAuthorityPolicy)) - r.MethodFunc("POST", "/policy", authorityPolicyMiddleware(policyResponder.CreateAuthorityPolicy)) - r.MethodFunc("PUT", "/policy", authorityPolicyMiddleware(policyResponder.UpdateAuthorityPolicy)) - r.MethodFunc("DELETE", "/policy", authorityPolicyMiddleware(policyResponder.DeleteAuthorityPolicy)) + r.MethodFunc("GET", "/policy", authorityPolicyMiddleware(router.policyResponder.GetAuthorityPolicy)) + r.MethodFunc("POST", "/policy", authorityPolicyMiddleware(router.policyResponder.CreateAuthorityPolicy)) + r.MethodFunc("PUT", "/policy", authorityPolicyMiddleware(router.policyResponder.UpdateAuthorityPolicy)) + r.MethodFunc("DELETE", "/policy", authorityPolicyMiddleware(router.policyResponder.DeleteAuthorityPolicy)) // Policy - Provisioner - r.MethodFunc("GET", "/provisioners/{provisionerName}/policy", provisionerPolicyMiddleware(policyResponder.GetProvisionerPolicy)) - r.MethodFunc("POST", "/provisioners/{provisionerName}/policy", provisionerPolicyMiddleware(policyResponder.CreateProvisionerPolicy)) - r.MethodFunc("PUT", "/provisioners/{provisionerName}/policy", provisionerPolicyMiddleware(policyResponder.UpdateProvisionerPolicy)) - r.MethodFunc("DELETE", "/provisioners/{provisionerName}/policy", provisionerPolicyMiddleware(policyResponder.DeleteProvisionerPolicy)) + r.MethodFunc("GET", "/provisioners/{provisionerName}/policy", provisionerPolicyMiddleware(router.policyResponder.GetProvisionerPolicy)) + r.MethodFunc("POST", "/provisioners/{provisionerName}/policy", provisionerPolicyMiddleware(router.policyResponder.CreateProvisionerPolicy)) + r.MethodFunc("PUT", "/provisioners/{provisionerName}/policy", provisionerPolicyMiddleware(router.policyResponder.UpdateProvisionerPolicy)) + r.MethodFunc("DELETE", "/provisioners/{provisionerName}/policy", provisionerPolicyMiddleware(router.policyResponder.DeleteProvisionerPolicy)) // Policy - ACME Account - r.MethodFunc("GET", "/acme/policy/{provisionerName}/reference/{reference}", acmePolicyMiddleware(policyResponder.GetACMEAccountPolicy)) - r.MethodFunc("GET", "/acme/policy/{provisionerName}/key/{keyID}", acmePolicyMiddleware(policyResponder.GetACMEAccountPolicy)) - r.MethodFunc("POST", "/acme/policy/{provisionerName}/reference/{reference}", acmePolicyMiddleware(policyResponder.CreateACMEAccountPolicy)) - r.MethodFunc("POST", "/acme/policy/{provisionerName}/key/{keyID}", acmePolicyMiddleware(policyResponder.CreateACMEAccountPolicy)) - r.MethodFunc("PUT", "/acme/policy/{provisionerName}/reference/{reference}", acmePolicyMiddleware(policyResponder.UpdateACMEAccountPolicy)) - r.MethodFunc("PUT", "/acme/policy/{provisionerName}/key/{keyID}", acmePolicyMiddleware(policyResponder.UpdateACMEAccountPolicy)) - r.MethodFunc("DELETE", "/acme/policy/{provisionerName}/reference/{reference}", acmePolicyMiddleware(policyResponder.DeleteACMEAccountPolicy)) - r.MethodFunc("DELETE", "/acme/policy/{provisionerName}/key/{keyID}", acmePolicyMiddleware(policyResponder.DeleteACMEAccountPolicy)) + r.MethodFunc("GET", "/acme/policy/{provisionerName}/reference/{reference}", acmePolicyMiddleware(router.policyResponder.GetACMEAccountPolicy)) + r.MethodFunc("GET", "/acme/policy/{provisionerName}/key/{keyID}", acmePolicyMiddleware(router.policyResponder.GetACMEAccountPolicy)) + r.MethodFunc("POST", "/acme/policy/{provisionerName}/reference/{reference}", acmePolicyMiddleware(router.policyResponder.CreateACMEAccountPolicy)) + r.MethodFunc("POST", "/acme/policy/{provisionerName}/key/{keyID}", acmePolicyMiddleware(router.policyResponder.CreateACMEAccountPolicy)) + r.MethodFunc("PUT", "/acme/policy/{provisionerName}/reference/{reference}", acmePolicyMiddleware(router.policyResponder.UpdateACMEAccountPolicy)) + r.MethodFunc("PUT", "/acme/policy/{provisionerName}/key/{keyID}", acmePolicyMiddleware(router.policyResponder.UpdateACMEAccountPolicy)) + r.MethodFunc("DELETE", "/acme/policy/{provisionerName}/reference/{reference}", acmePolicyMiddleware(router.policyResponder.DeleteACMEAccountPolicy)) + r.MethodFunc("DELETE", "/acme/policy/{provisionerName}/key/{keyID}", acmePolicyMiddleware(router.policyResponder.DeleteACMEAccountPolicy)) + } + + if router.webhookResponder != nil { + r.MethodFunc("POST", "/provisioners/{provisionerName}/webhooks", webhookMiddleware(router.webhookResponder.CreateProvisionerWebhook)) + r.MethodFunc("PUT", "/provisioners/{provisionerName}/webhooks/{webhookName}", webhookMiddleware(router.webhookResponder.UpdateProvisionerWebhook)) + r.MethodFunc("DELETE", "/provisioners/{provisionerName}/webhooks/{webhookName}", webhookMiddleware(router.webhookResponder.DeleteProvisionerWebhook)) } } diff --git a/authority/admin/api/webhook.go b/authority/admin/api/webhook.go new file mode 100644 index 00000000..f73f6806 --- /dev/null +++ b/authority/admin/api/webhook.go @@ -0,0 +1,235 @@ +package api + +import ( + "encoding/base64" + "fmt" + "net/http" + "net/url" + + "github.com/go-chi/chi" + "github.com/smallstep/certificates/api/read" + "github.com/smallstep/certificates/api/render" + "github.com/smallstep/certificates/authority/admin" + "go.step.sm/crypto/randutil" + "go.step.sm/linkedca" +) + +// WebhookAdminResponder is the interface responsible for writing webhook admin +// responses. +type WebhookAdminResponder interface { + CreateProvisionerWebhook(w http.ResponseWriter, r *http.Request) + UpdateProvisionerWebhook(w http.ResponseWriter, r *http.Request) + DeleteProvisionerWebhook(w http.ResponseWriter, r *http.Request) +} + +// webhoookAdminResponder implements WebhookAdminResponder +type webhookAdminResponder struct{} + +// NewWebhookAdminResponder returns a new WebhookAdminResponder +func NewWebhookAdminResponder() WebhookAdminResponder { + return &webhookAdminResponder{} +} + +func validateWebhook(webhook *linkedca.Webhook) error { + if webhook == nil { + return nil + } + + // name + if webhook.Name == "" { + return admin.NewError(admin.ErrorBadRequestType, "webhook name is required") + } + + // url + parsedURL, err := url.Parse(webhook.Url) + if err != nil { + return admin.NewError(admin.ErrorBadRequestType, "webhook url is invalid") + } + if parsedURL.Host == "" { + return admin.NewError(admin.ErrorBadRequestType, "webhook url is invalid") + } + if parsedURL.Scheme != "https" { + return admin.NewError(admin.ErrorBadRequestType, "webhook url must use https") + } + if parsedURL.User != nil { + return admin.NewError(admin.ErrorBadRequestType, "webhook url may not contain username or password") + } + + // kind + switch webhook.Kind { + case linkedca.Webhook_ENRICHING, linkedca.Webhook_AUTHORIZING: + default: + return admin.NewError(admin.ErrorBadRequestType, "webhook kind is invalid") + } + + return nil +} + +func (war *webhookAdminResponder) CreateProvisionerWebhook(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + auth := mustAuthority(ctx) + prov := linkedca.MustProvisionerFromContext(ctx) + + var newWebhook = new(linkedca.Webhook) + if err := read.ProtoJSON(r.Body, newWebhook); err != nil { + render.Error(w, err) + return + } + + if err := validateWebhook(newWebhook); err != nil { + render.Error(w, err) + return + } + if newWebhook.Secret != "" { + err := admin.NewError(admin.ErrorBadRequestType, "webhook secret must not be set") + render.Error(w, err) + return + } + if newWebhook.Id != "" { + err := admin.NewError(admin.ErrorBadRequestType, "webhook ID must not be set") + render.Error(w, err) + return + } + + id, err := randutil.UUIDv4() + if err != nil { + render.Error(w, admin.WrapErrorISE(err, "error generating webhook id")) + return + } + newWebhook.Id = id + + // verify the name is unique + for _, wh := range prov.Webhooks { + if wh.Name == newWebhook.Name { + err := admin.NewError(admin.ErrorConflictType, "provisioner %q already has a webhook with the name %q", prov.Name, newWebhook.Name) + render.Error(w, err) + return + } + } + + secret, err := randutil.Bytes(64) + if err != nil { + render.Error(w, admin.WrapErrorISE(err, "error generating webhook secret")) + return + } + newWebhook.Secret = base64.StdEncoding.EncodeToString(secret) + + prov.Webhooks = append(prov.Webhooks, newWebhook) + + if err := auth.UpdateProvisioner(ctx, prov); err != nil { + if isBadRequest(err) { + render.Error(w, admin.WrapError(admin.ErrorBadRequestType, err, "error creating provisioner webhook")) + return + } + + render.Error(w, admin.WrapErrorISE(err, "error creating provisioner webhook")) + return + } + + render.ProtoJSONStatus(w, newWebhook, http.StatusCreated) +} + +func (war *webhookAdminResponder) DeleteProvisionerWebhook(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + auth := mustAuthority(ctx) + prov := linkedca.MustProvisionerFromContext(ctx) + + webhookName := chi.URLParam(r, "webhookName") + + found := false + for i, wh := range prov.Webhooks { + if wh.Name == webhookName { + prov.Webhooks = append(prov.Webhooks[0:i], prov.Webhooks[i+1:]...) + found = true + break + } + } + if !found { + render.JSONStatus(w, DeleteResponse{Status: "ok"}, http.StatusOK) + return + } + + if err := auth.UpdateProvisioner(ctx, prov); err != nil { + if isBadRequest(err) { + render.Error(w, admin.WrapError(admin.ErrorBadRequestType, err, "error deleting provisioner webhook")) + return + } + + render.Error(w, admin.WrapErrorISE(err, "error deleting provisioner webhook")) + return + } + + render.JSONStatus(w, DeleteResponse{Status: "ok"}, http.StatusOK) +} + +func (war *webhookAdminResponder) UpdateProvisionerWebhook(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + auth := mustAuthority(ctx) + prov := linkedca.MustProvisionerFromContext(ctx) + + var newWebhook = new(linkedca.Webhook) + if err := read.ProtoJSON(r.Body, newWebhook); err != nil { + render.Error(w, err) + return + } + + if err := validateWebhook(newWebhook); err != nil { + render.Error(w, err) + return + } + + found := false + for i, wh := range prov.Webhooks { + if wh.Name != newWebhook.Name { + continue + } + if newWebhook.Secret != "" && newWebhook.Secret != wh.Secret { + err := admin.NewError(admin.ErrorBadRequestType, "webhook secret cannot be updated") + render.Error(w, err) + return + } + newWebhook.Secret = wh.Secret + if newWebhook.Id != "" && newWebhook.Id != wh.Id { + err := admin.NewError(admin.ErrorBadRequestType, "webhook ID cannot be updated") + render.Error(w, err) + return + } + newWebhook.Id = wh.Id + prov.Webhooks[i] = newWebhook + found = true + break + } + if !found { + msg := fmt.Sprintf("provisioner %q has no webhook with the name %q", prov.Name, newWebhook.Name) + err := admin.NewError(admin.ErrorNotFoundType, msg) + render.Error(w, err) + return + } + + if err := auth.UpdateProvisioner(ctx, prov); err != nil { + if isBadRequest(err) { + render.Error(w, admin.WrapError(admin.ErrorBadRequestType, err, "error updating provisioner webhook")) + return + } + + render.Error(w, admin.WrapErrorISE(err, "error updating provisioner webhook")) + return + } + + // Return a copy without the signing secret. Include the client-supplied + // auth secrets since those may have been updated in this request and we + // should show in the response that they changed + whResponse := &linkedca.Webhook{ + Id: newWebhook.Id, + Name: newWebhook.Name, + Url: newWebhook.Url, + Kind: newWebhook.Kind, + CertType: newWebhook.CertType, + Auth: newWebhook.Auth, + DisableTlsClientAuth: newWebhook.DisableTlsClientAuth, + } + render.ProtoJSONStatus(w, whResponse, http.StatusCreated) +} diff --git a/authority/admin/api/webhook_test.go b/authority/admin/api/webhook_test.go new file mode 100644 index 00000000..baac2c11 --- /dev/null +++ b/authority/admin/api/webhook_test.go @@ -0,0 +1,668 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi" + "github.com/smallstep/certificates/authority" + "github.com/smallstep/certificates/authority/admin" + "github.com/stretchr/testify/assert" + "go.step.sm/linkedca" + "google.golang.org/protobuf/encoding/protojson" +) + +// ignore secret and id since those are set by the server +func assertEqualWebhook(t *testing.T, a, b *linkedca.Webhook) { + assert.Equal(t, a.Name, b.Name) + assert.Equal(t, a.Url, b.Url) + assert.Equal(t, a.Kind, b.Kind) + assert.Equal(t, a.CertType, b.CertType) + assert.Equal(t, a.DisableTlsClientAuth, b.DisableTlsClientAuth) + + assert.Equal(t, a.GetAuth(), b.GetAuth()) +} + +func TestWebhookAdminResponder_CreateProvisionerWebhook(t *testing.T) { + type test struct { + auth adminAuthority + body []byte + ctx context.Context + err *admin.Error + response *linkedca.Webhook + statusCode int + } + var tests = map[string]func(t *testing.T) test{ + "fail/existing-webhook": func(t *testing.T) test { + webhook := &linkedca.Webhook{ + Name: "already-exists", + Url: "https://example.com", + } + prov := &linkedca.Provisioner{ + Name: "provName", + Webhooks: []*linkedca.Webhook{webhook}, + } + ctx := linkedca.NewContextWithProvisioner(context.Background(), prov) + err := admin.NewError(admin.ErrorConflictType, `provisioner "provName" already has a webhook with the name "already-exists"`) + err.Message = `provisioner "provName" already has a webhook with the name "already-exists"` + body := []byte(` + { + "name": "already-exists", + "url": "https://example.com", + "kind": "ENRICHING" + }`) + return test{ + ctx: ctx, + body: body, + err: err, + statusCode: 409, + } + }, + "fail/read.ProtoJSON": func(t *testing.T) test { + prov := &linkedca.Provisioner{ + Name: "provName", + } + ctx := linkedca.NewContextWithProvisioner(context.Background(), prov) + adminErr := admin.NewError(admin.ErrorBadRequestType, "proto: syntax error (line 1:2): invalid value ?") + adminErr.Message = "proto: syntax error (line 1:2): invalid value ?" + body := []byte("{?}") + return test{ + ctx: ctx, + body: body, + err: adminErr, + statusCode: 400, + } + }, + "fail/missing-name": func(t *testing.T) test { + prov := &linkedca.Provisioner{ + Name: "provName", + } + ctx := linkedca.NewContextWithProvisioner(context.Background(), prov) + adminErr := admin.NewError(admin.ErrorBadRequestType, "webhook name is required") + adminErr.Message = "webhook name is required" + body := []byte(`{"url": "https://example.com", "kind": "ENRICHING"}`) + return test{ + ctx: ctx, + body: body, + err: adminErr, + statusCode: 400, + } + }, + "fail/missing-url": func(t *testing.T) test { + prov := &linkedca.Provisioner{ + Name: "provName", + } + ctx := linkedca.NewContextWithProvisioner(context.Background(), prov) + adminErr := admin.NewError(admin.ErrorBadRequestType, "webhook url is invalid") + adminErr.Message = "webhook url is invalid" + body := []byte(`{"name": "metadata", "kind": "ENRICHING"}`) + return test{ + ctx: ctx, + body: body, + err: adminErr, + statusCode: 400, + } + }, + "fail/relative-url": func(t *testing.T) test { + prov := &linkedca.Provisioner{ + Name: "provName", + } + ctx := linkedca.NewContextWithProvisioner(context.Background(), prov) + adminErr := admin.NewError(admin.ErrorBadRequestType, "webhook url is invalid") + adminErr.Message = "webhook url is invalid" + body := []byte(`{"name": "metadata", "url": "example.com/path", "kind": "ENRICHING"}`) + return test{ + ctx: ctx, + body: body, + err: adminErr, + statusCode: 400, + } + }, + "fail/http-url": func(t *testing.T) test { + prov := &linkedca.Provisioner{ + Name: "provName", + } + ctx := linkedca.NewContextWithProvisioner(context.Background(), prov) + adminErr := admin.NewError(admin.ErrorBadRequestType, "webhook url must use https") + adminErr.Message = "webhook url must use https" + body := []byte(`{"name": "metadata", "url": "http://example.com", "kind": "ENRICHING"}`) + return test{ + ctx: ctx, + body: body, + err: adminErr, + statusCode: 400, + } + }, + "fail/basic-auth-in-url": func(t *testing.T) test { + prov := &linkedca.Provisioner{ + Name: "provName", + } + ctx := linkedca.NewContextWithProvisioner(context.Background(), prov) + adminErr := admin.NewError(admin.ErrorBadRequestType, "webhook url may not contain username or password") + adminErr.Message = "webhook url may not contain username or password" + body := []byte(` + { + "name": "metadata", + "url": "https://user:pass@example.com", + "kind": "ENRICHING" + }`) + return test{ + ctx: ctx, + body: body, + err: adminErr, + statusCode: 400, + } + }, + "fail/secret-in-request": func(t *testing.T) test { + prov := &linkedca.Provisioner{ + Name: "provName", + } + ctx := linkedca.NewContextWithProvisioner(context.Background(), prov) + adminErr := admin.NewError(admin.ErrorBadRequestType, "webhook secret must not be set") + adminErr.Message = "webhook secret must not be set" + body := []byte(` + { + "name": "metadata", + "url": "https://example.com", + "kind": "ENRICHING", + "secret": "secret" + }`) + return test{ + ctx: ctx, + body: body, + err: adminErr, + statusCode: 400, + } + }, + "fail/auth.UpdateProvisioner-error": func(t *testing.T) test { + adm := &linkedca.Admin{ + Subject: "step", + } + prov := &linkedca.Provisioner{ + Name: "provName", + } + ctx := linkedca.NewContextWithAdmin(context.Background(), adm) + ctx = linkedca.NewContextWithProvisioner(ctx, prov) + adminErr := admin.NewError(admin.ErrorServerInternalType, "error creating provisioner webhook: force") + adminErr.Message = "error creating provisioner webhook: force" + body := []byte(`{"name": "metadata", "url": "https://example.com", "kind": "ENRICHING"}`) + return test{ + ctx: ctx, + auth: &mockAdminAuthority{ + MockUpdateProvisioner: func(ctx context.Context, nu *linkedca.Provisioner) error { + return &authority.PolicyError{ + Typ: authority.StoreFailure, + Err: errors.New("force"), + } + }, + }, + body: body, + err: adminErr, + statusCode: 500, + } + }, + "ok": func(t *testing.T) test { + prov := &linkedca.Provisioner{ + Name: "provName", + } + ctx := linkedca.NewContextWithProvisioner(context.Background(), prov) + body := []byte(`{"name": "metadata", "url": "https://example.com", "kind": "ENRICHING", "certType": "X509"}`) + return test{ + ctx: ctx, + auth: &mockAdminAuthority{ + MockUpdateProvisioner: func(ctx context.Context, nu *linkedca.Provisioner) error { + assert.Equal(t, linkedca.Webhook_X509, nu.Webhooks[0].CertType) + return nil + }, + }, + body: body, + response: &linkedca.Webhook{ + Name: "metadata", + Url: "https://example.com", + Kind: linkedca.Webhook_ENRICHING, + CertType: linkedca.Webhook_X509, + }, + statusCode: 201, + } + }, + } + for name, prep := range tests { + tc := prep(t) + t.Run(name, func(t *testing.T) { + mockMustAuthority(t, tc.auth) + ctx := admin.NewContext(tc.ctx, &admin.MockDB{}) + war := NewWebhookAdminResponder() + + req := httptest.NewRequest("POST", "/foo", io.NopCloser(bytes.NewBuffer(tc.body))) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + + war.CreateProvisionerWebhook(w, req) + res := w.Result() + + assert.Equal(t, tc.statusCode, res.StatusCode) + + if res.StatusCode >= 400 { + + body, err := io.ReadAll(res.Body) + res.Body.Close() + assert.NoError(t, err) + + ae := testAdminError{} + assert.NoError(t, json.Unmarshal(bytes.TrimSpace(body), &ae)) + + assert.Equal(t, tc.err.Type, ae.Type) + assert.Equal(t, tc.err.StatusCode(), res.StatusCode) + assert.Equal(t, tc.err.Detail, ae.Detail) + assert.Equal(t, []string{"application/json"}, res.Header["Content-Type"]) + + // when the error message starts with "proto", we expect it to have + // a syntax error (in the tests). If the message doesn't start with "proto", + // we expect a full string match. + if strings.HasPrefix(tc.err.Message, "proto:") { + assert.True(t, strings.Contains(ae.Message, "syntax error")) + } else { + assert.Equal(t, tc.err.Message, ae.Message) + } + + return + } + + resp := &linkedca.Webhook{} + body, err := io.ReadAll(res.Body) + assert.NoError(t, err) + assert.NoError(t, protojson.Unmarshal(body, resp)) + + assertEqualWebhook(t, tc.response, resp) + assert.NotEmpty(t, resp.Secret) + assert.NotEmpty(t, resp.Id) + }) + } +} + +func TestWebhookAdminResponder_DeleteProvisionerWebhook(t *testing.T) { + type test struct { + auth adminAuthority + err *admin.Error + statusCode int + provisionerWebhooks []*linkedca.Webhook + webhookName string + } + var tests = map[string]func(t *testing.T) test{ + "fail/auth.UpdateProvisioner-error": func(t *testing.T) test { + adminErr := admin.NewError(admin.ErrorServerInternalType, "error deleting provisioner webhook: force") + adminErr.Message = "error deleting provisioner webhook: force" + return test{ + err: adminErr, + auth: &mockAdminAuthority{ + MockUpdateProvisioner: func(ctx context.Context, nu *linkedca.Provisioner) error { + return &authority.PolicyError{ + Typ: authority.StoreFailure, + Err: errors.New("force"), + } + }, + }, + statusCode: 500, + webhookName: "my-webhook", + provisionerWebhooks: []*linkedca.Webhook{ + {Name: "my-webhook", Url: "https://example.com", Kind: linkedca.Webhook_ENRICHING}, + }, + } + }, + "ok/not-found": func(t *testing.T) test { + return test{ + statusCode: 200, + webhookName: "no-exists", + provisionerWebhooks: nil, + } + }, + "ok": func(t *testing.T) test { + return test{ + statusCode: 200, + webhookName: "exists", + auth: &mockAdminAuthority{ + MockUpdateProvisioner: func(ctx context.Context, nu *linkedca.Provisioner) error { + assert.Equal(t, nu.Webhooks, []*linkedca.Webhook{ + {Name: "my-2nd-webhook", Url: "https://example.com", Kind: linkedca.Webhook_ENRICHING}, + }) + return nil + }, + }, + provisionerWebhooks: []*linkedca.Webhook{ + {Name: "exists", Url: "https.example.com", Kind: linkedca.Webhook_ENRICHING}, + {Name: "my-2nd-webhook", Url: "https://example.com", Kind: linkedca.Webhook_ENRICHING}, + }, + } + }, + } + for name, prep := range tests { + tc := prep(t) + t.Run(name, func(t *testing.T) { + mockMustAuthority(t, tc.auth) + + chiCtx := chi.NewRouteContext() + chiCtx.URLParams.Add("webhookName", tc.webhookName) + ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx) + prov := &linkedca.Provisioner{ + Name: "provName", + Webhooks: tc.provisionerWebhooks, + } + ctx = linkedca.NewContextWithProvisioner(ctx, prov) + ctx = admin.NewContext(ctx, &admin.MockDB{}) + req := httptest.NewRequest("DELETE", "/foo", nil).WithContext(ctx) + + war := NewWebhookAdminResponder() + + w := httptest.NewRecorder() + + war.DeleteProvisionerWebhook(w, req) + res := w.Result() + + assert.Equal(t, tc.statusCode, res.StatusCode) + + if res.StatusCode >= 400 { + + body, err := io.ReadAll(res.Body) + res.Body.Close() + assert.NoError(t, err) + + ae := testAdminError{} + assert.NoError(t, json.Unmarshal(bytes.TrimSpace(body), &ae)) + + assert.Equal(t, tc.err.Type, ae.Type) + assert.Equal(t, tc.err.StatusCode(), res.StatusCode) + assert.Equal(t, tc.err.Detail, ae.Detail) + assert.Equal(t, []string{"application/json"}, res.Header["Content-Type"]) + + // when the error message starts with "proto", we expect it to have + // a syntax error (in the tests). If the message doesn't start with "proto", + // we expect a full string match. + if strings.HasPrefix(tc.err.Message, "proto:") { + assert.True(t, strings.Contains(ae.Message, "syntax error")) + } else { + assert.Equal(t, tc.err.Message, ae.Message) + } + + return + } + + body, err := io.ReadAll(res.Body) + assert.NoError(t, err) + res.Body.Close() + response := DeleteResponse{} + assert.NoError(t, json.Unmarshal(bytes.TrimSpace(body), &response)) + assert.Equal(t, "ok", response.Status) + assert.Equal(t, []string{"application/json"}, res.Header["Content-Type"]) + }) + } +} + +func TestWebhookAdminResponder_UpdateProvisionerWebhook(t *testing.T) { + type test struct { + auth adminAuthority + adminDB admin.DB + body []byte + ctx context.Context + err *admin.Error + response *linkedca.Webhook + statusCode int + } + var tests = map[string]func(t *testing.T) test{ + "fail/not-found": func(t *testing.T) test { + prov := &linkedca.Provisioner{ + Name: "provName", + Webhooks: []*linkedca.Webhook{{Name: "exists", Url: "https://example.com", Kind: linkedca.Webhook_ENRICHING}}, + } + ctx := linkedca.NewContextWithProvisioner(context.Background(), prov) + err := admin.NewError(admin.ErrorNotFoundType, `provisioner "provName" has no webhook with the name "no-exists"`) + err.Message = `provisioner "provName" has no webhook with the name "no-exists"` + body := []byte(` + { + "name": "no-exists", + "url": "https://example.com", + "kind": "ENRICHING" + }`) + return test{ + ctx: ctx, + adminDB: &admin.MockDB{}, + body: body, + err: err, + statusCode: 404, + } + }, + "fail/read.ProtoJSON": func(t *testing.T) test { + prov := &linkedca.Provisioner{ + Name: "provName", + Webhooks: []*linkedca.Webhook{{Name: "my-webhook", Url: "https://example.com", Kind: linkedca.Webhook_ENRICHING}}, + } + ctx := linkedca.NewContextWithProvisioner(context.Background(), prov) + adminErr := admin.NewError(admin.ErrorBadRequestType, "proto: syntax error (line 1:2): invalid value ?") + adminErr.Message = "proto: syntax error (line 1:2): invalid value ?" + body := []byte("{?}") + return test{ + ctx: ctx, + adminDB: &admin.MockDB{}, + body: body, + err: adminErr, + statusCode: 400, + } + }, + "fail/missing-name": func(t *testing.T) test { + prov := &linkedca.Provisioner{ + Name: "provName", + Webhooks: []*linkedca.Webhook{{Name: "my-webhook", Url: "https://example.com", Kind: linkedca.Webhook_ENRICHING}}, + } + ctx := linkedca.NewContextWithProvisioner(context.Background(), prov) + adminErr := admin.NewError(admin.ErrorBadRequestType, "webhook name is required") + adminErr.Message = "webhook name is required" + body := []byte(`{"url": "https://example.com", "kind": "ENRICHING"}`) + return test{ + ctx: ctx, + adminDB: &admin.MockDB{}, + body: body, + err: adminErr, + statusCode: 400, + } + }, + "fail/missing-url": func(t *testing.T) test { + prov := &linkedca.Provisioner{ + Name: "provName", + Webhooks: []*linkedca.Webhook{{Name: "my-webhook", Url: "https://example.com", Kind: linkedca.Webhook_ENRICHING}}, + } + ctx := linkedca.NewContextWithProvisioner(context.Background(), prov) + adminErr := admin.NewError(admin.ErrorBadRequestType, "webhook url is invalid") + adminErr.Message = "webhook url is invalid" + body := []byte(`{"name": "metadata", "kind": "ENRICHING"}`) + return test{ + ctx: ctx, + adminDB: &admin.MockDB{}, + body: body, + err: adminErr, + statusCode: 400, + } + }, + "fail/relative-url": func(t *testing.T) test { + prov := &linkedca.Provisioner{ + Name: "provName", + Webhooks: []*linkedca.Webhook{{Name: "my-webhook", Url: "https://example.com", Kind: linkedca.Webhook_ENRICHING}}, + } + ctx := linkedca.NewContextWithProvisioner(context.Background(), prov) + adminErr := admin.NewError(admin.ErrorBadRequestType, "webhook url is invalid") + adminErr.Message = "webhook url is invalid" + body := []byte(`{"name": "metadata", "url": "example.com/path", "kind": "ENRICHING"}`) + return test{ + ctx: ctx, + adminDB: &admin.MockDB{}, + body: body, + err: adminErr, + statusCode: 400, + } + }, + "fail/http-url": func(t *testing.T) test { + prov := &linkedca.Provisioner{ + Name: "provName", + Webhooks: []*linkedca.Webhook{{Name: "my-webhook", Url: "https://example.com", Kind: linkedca.Webhook_ENRICHING}}, + } + ctx := linkedca.NewContextWithProvisioner(context.Background(), prov) + adminErr := admin.NewError(admin.ErrorBadRequestType, "webhook url must use https") + adminErr.Message = "webhook url must use https" + body := []byte(`{"name": "metadata", "url": "http://example.com", "kind": "ENRICHING"}`) + return test{ + ctx: ctx, + adminDB: &admin.MockDB{}, + body: body, + err: adminErr, + statusCode: 400, + } + }, + "fail/basic-auth-in-url": func(t *testing.T) test { + prov := &linkedca.Provisioner{ + Name: "provName", + Webhooks: []*linkedca.Webhook{{Name: "my-webhook", Url: "https://example.com", Kind: linkedca.Webhook_ENRICHING}}, + } + ctx := linkedca.NewContextWithProvisioner(context.Background(), prov) + adminErr := admin.NewError(admin.ErrorBadRequestType, "webhook url may not contain username or password") + adminErr.Message = "webhook url may not contain username or password" + body := []byte(` + { + "name": "my-webhook", + "url": "https://user:pass@example.com", + "kind": "ENRICHING" + }`) + return test{ + ctx: ctx, + adminDB: &admin.MockDB{}, + body: body, + err: adminErr, + statusCode: 400, + } + }, + "fail/different-secret-in-request": func(t *testing.T) test { + prov := &linkedca.Provisioner{ + Name: "provName", + Webhooks: []*linkedca.Webhook{{Name: "my-webhook", Url: "https://example.com", Kind: linkedca.Webhook_ENRICHING, Secret: "c2VjcmV0"}}, + } + ctx := linkedca.NewContextWithProvisioner(context.Background(), prov) + adminErr := admin.NewError(admin.ErrorBadRequestType, "webhook secret cannot be updated") + adminErr.Message = "webhook secret cannot be updated" + body := []byte(` + { + "name": "my-webhook", + "url": "https://example.com", + "kind": "ENRICHING", + "secret": "secret" + }`) + return test{ + ctx: ctx, + body: body, + err: adminErr, + statusCode: 400, + } + }, + "fail/auth.UpdateProvisioner-error": func(t *testing.T) test { + prov := &linkedca.Provisioner{ + Name: "provName", + Webhooks: []*linkedca.Webhook{{Name: "my-webhook", Url: "https://example.com", Kind: linkedca.Webhook_ENRICHING}}, + } + ctx := linkedca.NewContextWithProvisioner(context.Background(), prov) + adminErr := admin.NewError(admin.ErrorServerInternalType, "error updating provisioner webhook: force") + adminErr.Message = "error updating provisioner webhook: force" + body := []byte(`{"name": "my-webhook", "url": "https://example.com", "kind": "ENRICHING"}`) + return test{ + ctx: ctx, + adminDB: &admin.MockDB{}, + auth: &mockAdminAuthority{ + MockUpdateProvisioner: func(ctx context.Context, nu *linkedca.Provisioner) error { + return &authority.PolicyError{ + Typ: authority.StoreFailure, + Err: errors.New("force"), + } + }, + }, + body: body, + err: adminErr, + statusCode: 500, + } + }, + "ok": func(t *testing.T) test { + prov := &linkedca.Provisioner{ + Name: "provName", + Webhooks: []*linkedca.Webhook{{Name: "my-webhook", Url: "https://example.com", Kind: linkedca.Webhook_ENRICHING}}, + } + ctx := linkedca.NewContextWithProvisioner(context.Background(), prov) + body := []byte(`{"name": "my-webhook", "url": "https://example.com", "kind": "ENRICHING"}`) + return test{ + ctx: ctx, + adminDB: &admin.MockDB{}, + auth: &mockAdminAuthority{ + MockUpdateProvisioner: func(ctx context.Context, nu *linkedca.Provisioner) error { + return nil + }, + }, + body: body, + response: &linkedca.Webhook{ + Name: "my-webhook", + Url: "https://example.com", + Kind: linkedca.Webhook_ENRICHING, + }, + statusCode: 201, + } + }, + } + for name, prep := range tests { + tc := prep(t) + t.Run(name, func(t *testing.T) { + mockMustAuthority(t, tc.auth) + ctx := admin.NewContext(tc.ctx, tc.adminDB) + war := NewWebhookAdminResponder() + + req := httptest.NewRequest("PUT", "/foo", io.NopCloser(bytes.NewBuffer(tc.body))) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + + war.UpdateProvisionerWebhook(w, req) + res := w.Result() + + assert.Equal(t, tc.statusCode, res.StatusCode) + + if res.StatusCode >= 400 { + + body, err := io.ReadAll(res.Body) + res.Body.Close() + assert.NoError(t, err) + + ae := testAdminError{} + assert.NoError(t, json.Unmarshal(bytes.TrimSpace(body), &ae)) + + assert.Equal(t, tc.err.Type, ae.Type) + assert.Equal(t, tc.err.StatusCode(), res.StatusCode) + assert.Equal(t, tc.err.Detail, ae.Detail) + assert.Equal(t, []string{"application/json"}, res.Header["Content-Type"]) + + // when the error message starts with "proto", we expect it to have + // a syntax error (in the tests). If the message doesn't start with "proto", + // we expect a full string match. + if strings.HasPrefix(tc.err.Message, "proto:") { + assert.True(t, strings.Contains(ae.Message, "syntax error")) + } else { + assert.Equal(t, tc.err.Message, ae.Message) + } + + return + } + + resp := &linkedca.Webhook{} + body, err := io.ReadAll(res.Body) + assert.NoError(t, err) + assert.NoError(t, protojson.Unmarshal(body, resp)) + + assertEqualWebhook(t, tc.response, resp) + }) + } +} diff --git a/authority/admin/db/nosql/provisioner.go b/authority/admin/db/nosql/provisioner.go index c82d4afe..da116e0b 100644 --- a/authority/admin/db/nosql/provisioner.go +++ b/authority/admin/db/nosql/provisioner.go @@ -24,6 +24,24 @@ type dbProvisioner struct { SSHTemplate *linkedca.Template `json:"sshTemplate"` CreatedAt time.Time `json:"createdAt"` DeletedAt time.Time `json:"deletedAt"` + Webhooks []dbWebhook `json:"webhooks,omitempty"` +} + +type dbBasicAuth struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type dbWebhook struct { + Name string `json:"name"` + ID string `json:"id"` + URL string `json:"url"` + Kind string `json:"kind"` + Secret string `json:"secret"` + BearerToken string `json:"bearerToken,omitempty"` + BasicAuth *dbBasicAuth `json:"basicAuth,omitempty"` + DisableTLSClientAuth bool `json:"disableTLSClientAuth,omitempty"` + CertType string `json:"certType,omitempty"` } func (dbp *dbProvisioner) clone() *dbProvisioner { @@ -48,6 +66,7 @@ func (dbp *dbProvisioner) convert2linkedca() (*linkedca.Provisioner, error) { SshTemplate: dbp.SSHTemplate, CreatedAt: timestamppb.New(dbp.CreatedAt), DeletedAt: timestamppb.New(dbp.DeletedAt), + Webhooks: dbWebhooksToLinkedca(dbp.Webhooks), }, nil } @@ -164,6 +183,7 @@ func (db *DB) CreateProvisioner(ctx context.Context, prov *linkedca.Provisioner) X509Template: prov.X509Template, SSHTemplate: prov.SshTemplate, CreatedAt: clock.Now(), + Webhooks: linkedcaWebhooksToDB(prov.Webhooks), } if err := db.save(ctx, prov.Id, dbp, nil, "provisioner", provisionersTable); err != nil { @@ -193,6 +213,7 @@ func (db *DB) UpdateProvisioner(ctx context.Context, prov *linkedca.Provisioner) } nu.X509Template = prov.X509Template nu.SSHTemplate = prov.SshTemplate + nu.Webhooks = linkedcaWebhooksToDB(prov.Webhooks) return db.save(ctx, prov.Id, nu, old, "provisioner", provisionersTable) } @@ -209,3 +230,70 @@ func (db *DB) DeleteProvisioner(ctx context.Context, id string) error { return db.save(ctx, old.ID, nu, old, "provisioner", provisionersTable) } + +func dbWebhooksToLinkedca(dbwhs []dbWebhook) []*linkedca.Webhook { + if len(dbwhs) == 0 { + return nil + } + lwhs := make([]*linkedca.Webhook, len(dbwhs)) + + for i, dbwh := range dbwhs { + lwh := &linkedca.Webhook{ + Name: dbwh.Name, + Id: dbwh.ID, + Url: dbwh.URL, + Kind: linkedca.Webhook_Kind(linkedca.Webhook_Kind_value[dbwh.Kind]), + Secret: dbwh.Secret, + DisableTlsClientAuth: dbwh.DisableTLSClientAuth, + CertType: linkedca.Webhook_CertType(linkedca.Webhook_CertType_value[dbwh.CertType]), + } + if dbwh.BearerToken != "" { + lwh.Auth = &linkedca.Webhook_BearerToken{ + BearerToken: &linkedca.BearerToken{ + BearerToken: dbwh.BearerToken, + }, + } + } else if dbwh.BasicAuth != nil && (dbwh.BasicAuth.Username != "" || dbwh.BasicAuth.Password != "") { + lwh.Auth = &linkedca.Webhook_BasicAuth{ + BasicAuth: &linkedca.BasicAuth{ + Username: dbwh.BasicAuth.Username, + Password: dbwh.BasicAuth.Password, + }, + } + } + lwhs[i] = lwh + } + + return lwhs +} + +func linkedcaWebhooksToDB(lwhs []*linkedca.Webhook) []dbWebhook { + if len(lwhs) == 0 { + return nil + } + dbwhs := make([]dbWebhook, len(lwhs)) + + for i, lwh := range lwhs { + dbwh := dbWebhook{ + Name: lwh.Name, + ID: lwh.Id, + URL: lwh.Url, + Kind: lwh.Kind.String(), + Secret: lwh.Secret, + DisableTLSClientAuth: lwh.DisableTlsClientAuth, + CertType: lwh.CertType.String(), + } + switch a := lwh.GetAuth().(type) { + case *linkedca.Webhook_BearerToken: + dbwh.BearerToken = a.BearerToken.BearerToken + case *linkedca.Webhook_BasicAuth: + dbwh.BasicAuth = &dbBasicAuth{ + Username: a.BasicAuth.Username, + Password: a.BasicAuth.Password, + } + } + dbwhs[i] = dbwh + } + + return dbwhs +} diff --git a/authority/admin/db/nosql/provisioner_test.go b/authority/admin/db/nosql/provisioner_test.go index c5caf696..8aa58d49 100644 --- a/authority/admin/db/nosql/provisioner_test.go +++ b/authority/admin/db/nosql/provisioner_test.go @@ -137,6 +137,7 @@ func TestDB_getDBProvisioner(t *testing.T) { } }, "fail/deleted": func(t *testing.T) test { + now := clock.Now() dbp := &dbProvisioner{ ID: provID, @@ -210,6 +211,7 @@ func TestDB_getDBProvisioner(t *testing.T) { assert.Equals(t, dbp.Name, tc.dbp.Name) assert.Equals(t, dbp.CreatedAt, tc.dbp.CreatedAt) assert.Fatal(t, dbp.DeletedAt.IsZero()) + assert.Equals(t, dbp.Webhooks, tc.dbp.Webhooks) } }) } @@ -300,6 +302,7 @@ func TestDB_unmarshalDBProvisioner(t *testing.T) { assert.Equals(t, dbp.SSHTemplate, tc.dbp.SSHTemplate) assert.Equals(t, dbp.CreatedAt, tc.dbp.CreatedAt) assert.Fatal(t, dbp.DeletedAt.IsZero()) + assert.Equals(t, dbp.Webhooks, tc.dbp.Webhooks) } }) } @@ -353,6 +356,15 @@ func defaultDBP(t *testing.T) *dbProvisioner { Data: []byte("zap"), }, CreatedAt: clock.Now(), + Webhooks: []dbWebhook{ + { + Name: "metadata", + URL: "https://inventory.smallstep.com", + Kind: linkedca.Webhook_ENRICHING.String(), + Secret: "secret", + BearerToken: "token", + }, + }, } } @@ -419,6 +431,7 @@ func TestDB_unmarshalProvisioner(t *testing.T) { assert.Equals(t, prov.Claims, tc.dbp.Claims) assert.Equals(t, prov.X509Template, tc.dbp.X509Template) assert.Equals(t, prov.SshTemplate, tc.dbp.SSHTemplate) + assert.Equals(t, prov.Webhooks, dbWebhooksToLinkedca(tc.dbp.Webhooks)) retDetailsBytes, err := json.Marshal(prov.Details.GetData()) assert.FatalError(t, err) @@ -557,6 +570,7 @@ func TestDB_GetProvisioner(t *testing.T) { assert.Equals(t, prov.Claims, tc.dbp.Claims) assert.Equals(t, prov.X509Template, tc.dbp.X509Template) assert.Equals(t, prov.SshTemplate, tc.dbp.SSHTemplate) + assert.Equals(t, prov.Webhooks, dbWebhooksToLinkedca(tc.dbp.Webhooks)) retDetailsBytes, err := json.Marshal(prov.Details.GetData()) assert.FatalError(t, err) @@ -629,6 +643,7 @@ func TestDB_DeleteProvisioner(t *testing.T) { assert.Equals(t, _dbp.SSHTemplate, dbp.SSHTemplate) assert.Equals(t, _dbp.CreatedAt, dbp.CreatedAt) assert.Equals(t, _dbp.Details, dbp.Details) + assert.Equals(t, _dbp.Webhooks, dbp.Webhooks) assert.True(t, _dbp.DeletedAt.Before(time.Now())) assert.True(t, _dbp.DeletedAt.After(time.Now().Add(-time.Minute))) @@ -668,6 +683,7 @@ func TestDB_DeleteProvisioner(t *testing.T) { assert.Equals(t, _dbp.SSHTemplate, dbp.SSHTemplate) assert.Equals(t, _dbp.CreatedAt, dbp.CreatedAt) assert.Equals(t, _dbp.Details, dbp.Details) + assert.Equals(t, _dbp.Webhooks, dbp.Webhooks) assert.True(t, _dbp.DeletedAt.Before(time.Now())) assert.True(t, _dbp.DeletedAt.After(time.Now().Add(-time.Minute))) @@ -819,6 +835,7 @@ func TestDB_GetProvisioners(t *testing.T) { assert.Equals(t, provs[0].Claims, fooProv.Claims) assert.Equals(t, provs[0].X509Template, fooProv.X509Template) assert.Equals(t, provs[0].SshTemplate, fooProv.SSHTemplate) + assert.Equals(t, provs[0].Webhooks, dbWebhooksToLinkedca(fooProv.Webhooks)) retDetailsBytes, err := json.Marshal(provs[0].Details.GetData()) assert.FatalError(t, err) @@ -831,6 +848,7 @@ func TestDB_GetProvisioners(t *testing.T) { assert.Equals(t, provs[1].Claims, zapProv.Claims) assert.Equals(t, provs[1].X509Template, zapProv.X509Template) assert.Equals(t, provs[1].SshTemplate, zapProv.SSHTemplate) + assert.Equals(t, provs[1].Webhooks, dbWebhooksToLinkedca(zapProv.Webhooks)) retDetailsBytes, err = json.Marshal(provs[1].Details.GetData()) assert.FatalError(t, err) @@ -895,6 +913,7 @@ func TestDB_CreateProvisioner(t *testing.T) { assert.Equals(t, _dbp.Claims, prov.Claims) assert.Equals(t, _dbp.X509Template, prov.X509Template) assert.Equals(t, _dbp.SSHTemplate, prov.SshTemplate) + assert.Equals(t, _dbp.Webhooks, linkedcaWebhooksToDB(prov.Webhooks)) retDetailsBytes, err := json.Marshal(prov.Details.GetData()) assert.FatalError(t, err) @@ -932,6 +951,7 @@ func TestDB_CreateProvisioner(t *testing.T) { assert.Equals(t, _dbp.Claims, prov.Claims) assert.Equals(t, _dbp.X509Template, prov.X509Template) assert.Equals(t, _dbp.SSHTemplate, prov.SshTemplate) + assert.Equals(t, _dbp.Webhooks, linkedcaWebhooksToDB(prov.Webhooks)) retDetailsBytes, err := json.Marshal(prov.Details.GetData()) assert.FatalError(t, err) @@ -1080,6 +1100,7 @@ func TestDB_UpdateProvisioner(t *testing.T) { assert.Equals(t, _dbp.Claims, prov.Claims) assert.Equals(t, _dbp.X509Template, prov.X509Template) assert.Equals(t, _dbp.SSHTemplate, prov.SshTemplate) + assert.Equals(t, _dbp.Webhooks, linkedcaWebhooksToDB(prov.Webhooks)) retDetailsBytes, err := json.Marshal(prov.Details.GetData()) assert.FatalError(t, err) @@ -1141,6 +1162,12 @@ func TestDB_UpdateProvisioner(t *testing.T) { }, }, } + prov.Webhooks = []*linkedca.Webhook{ + { + Name: "users", + Url: "https://example.com/users", + }, + } data, err := json.Marshal(dbp) assert.FatalError(t, err) @@ -1168,6 +1195,7 @@ func TestDB_UpdateProvisioner(t *testing.T) { assert.Equals(t, _dbp.Claims, prov.Claims) assert.Equals(t, _dbp.X509Template, prov.X509Template) assert.Equals(t, _dbp.SSHTemplate, prov.SshTemplate) + assert.Equals(t, _dbp.Webhooks, linkedcaWebhooksToDB(prov.Webhooks)) retDetailsBytes, err := json.Marshal(prov.Details.GetData()) assert.FatalError(t, err) @@ -1206,3 +1234,164 @@ func TestDB_UpdateProvisioner(t *testing.T) { }) } } + +func Test_linkedcaWebhooksToDB(t *testing.T) { + type test struct { + in []*linkedca.Webhook + want []dbWebhook + } + var tests = map[string]test{ + "nil": { + in: nil, + want: nil, + }, + "zero": { + in: []*linkedca.Webhook{}, + want: nil, + }, + "bearer": { + in: []*linkedca.Webhook{ + { + Name: "bearer", + Url: "https://example.com", + Kind: linkedca.Webhook_ENRICHING, + Secret: "secret", + Auth: &linkedca.Webhook_BearerToken{ + BearerToken: &linkedca.BearerToken{ + BearerToken: "token", + }, + }, + DisableTlsClientAuth: true, + CertType: linkedca.Webhook_X509, + }, + }, + want: []dbWebhook{ + { + Name: "bearer", + URL: "https://example.com", + Kind: "ENRICHING", + Secret: "secret", + BearerToken: "token", + DisableTLSClientAuth: true, + CertType: linkedca.Webhook_X509.String(), + }, + }, + }, + "basic": { + in: []*linkedca.Webhook{ + { + Name: "basic", + Url: "https://example.com", + Kind: linkedca.Webhook_ENRICHING, + Secret: "secret", + Auth: &linkedca.Webhook_BasicAuth{ + BasicAuth: &linkedca.BasicAuth{ + Username: "user", + Password: "pass", + }, + }, + }, + }, + want: []dbWebhook{ + { + Name: "basic", + URL: "https://example.com", + Kind: "ENRICHING", + Secret: "secret", + BasicAuth: &dbBasicAuth{ + Username: "user", + Password: "pass", + }, + CertType: linkedca.Webhook_ALL.String(), + }, + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := linkedcaWebhooksToDB(tc.in) + assert.Equals(t, tc.want, got) + }) + } +} + +func Test_dbWebhooksToLinkedca(t *testing.T) { + type test struct { + in []dbWebhook + want []*linkedca.Webhook + } + var tests = map[string]test{ + "nil": { + in: nil, + want: nil, + }, + "zero": { + in: []dbWebhook{}, + want: nil, + }, + "bearer": { + in: []dbWebhook{ + { + Name: "bearer", + ID: "69350cb6-6c31-4b5e-bf25-affd5053427d", + URL: "https://example.com", + Kind: "ENRICHING", + Secret: "secret", + BearerToken: "token", + DisableTLSClientAuth: true, + }, + }, + want: []*linkedca.Webhook{ + { + Name: "bearer", + Id: "69350cb6-6c31-4b5e-bf25-affd5053427d", + Url: "https://example.com", + Kind: linkedca.Webhook_ENRICHING, + Secret: "secret", + Auth: &linkedca.Webhook_BearerToken{ + BearerToken: &linkedca.BearerToken{ + BearerToken: "token", + }, + }, + DisableTlsClientAuth: true, + }, + }, + }, + "basic": { + in: []dbWebhook{ + { + Name: "basic", + ID: "69350cb6-6c31-4b5e-bf25-affd5053427d", + URL: "https://example.com", + Kind: "ENRICHING", + Secret: "secret", + BasicAuth: &dbBasicAuth{ + Username: "user", + Password: "pass", + }, + }, + }, + want: []*linkedca.Webhook{ + { + Name: "basic", + Id: "69350cb6-6c31-4b5e-bf25-affd5053427d", + Url: "https://example.com", + Kind: linkedca.Webhook_ENRICHING, + Secret: "secret", + Auth: &linkedca.Webhook_BasicAuth{ + BasicAuth: &linkedca.BasicAuth{ + Username: "user", + Password: "pass", + }, + }, + }, + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := dbWebhooksToLinkedca(tc.in) + assert.Equals(t, tc.want, got) + }) + } +} diff --git a/authority/authority.go b/authority/authority.go index 7d527b13..5271842d 100644 --- a/authority/authority.go +++ b/authority/authority.go @@ -8,6 +8,7 @@ import ( "crypto/x509" "encoding/hex" "log" + "net/http" "strings" "sync" "time" @@ -46,6 +47,7 @@ type Authority struct { adminDB admin.DB templates *templates.Templates linkedCAToken string + webhookClient *http.Client // X509 CA password []byte diff --git a/authority/authorize_test.go b/authority/authorize_test.go index 8f49cf03..7dc22f3a 100644 --- a/authority/authorize_test.go +++ b/authority/authorize_test.go @@ -491,7 +491,7 @@ func TestAuthority_authorizeSign(t *testing.T) { } } else { if assert.Nil(t, tc.err) { - assert.Equals(t, 9, len(got)) // number of provisioner.SignOptions returned + assert.Equals(t, 10, len(got)) // number of provisioner.SignOptions returned } } }) @@ -1034,7 +1034,7 @@ func TestAuthority_authorizeSSHSign(t *testing.T) { } } else { if assert.Nil(t, tc.err) { - assert.Len(t, 9, got) // number of provisioner.SignOptions returned + assert.Len(t, 10, got) // number of provisioner.SignOptions returned } } }) diff --git a/authority/options.go b/authority/options.go index 8e1a01ff..f332d4a9 100644 --- a/authority/options.go +++ b/authority/options.go @@ -5,6 +5,7 @@ import ( "crypto" "crypto/x509" "encoding/pem" + "net/http" "github.com/pkg/errors" "golang.org/x/crypto/ssh" @@ -85,6 +86,14 @@ func WithDatabase(d db.AuthDB) Option { } } +// WithWebhookClient sets the http.Client to be used for outbound requests. +func WithWebhookClient(c *http.Client) Option { + return func(a *Authority) error { + a.webhookClient = c + return nil + } +} + // WithGetIdentityFunc sets a custom function to retrieve the identity from // an external resource. func WithGetIdentityFunc(fn func(ctx context.Context, p provisioner.Interface, email string) (*provisioner.Identity, error)) Option { diff --git a/authority/provisioner/acme.go b/authority/provisioner/acme.go index 9a5e9f1c..d68c0b93 100644 --- a/authority/provisioner/acme.go +++ b/authority/provisioner/acme.go @@ -10,6 +10,7 @@ import ( "time" "github.com/pkg/errors" + "go.step.sm/linkedca" ) // ACMEChallenge represents the supported acme challenges. @@ -252,6 +253,7 @@ func (p *ACME) AuthorizeSign(ctx context.Context, token string) ([]SignOption, e defaultPublicKeyValidator{}, newValidityValidator(p.ctl.Claimer.MinTLSCertDuration(), p.ctl.Claimer.MaxTLSCertDuration()), newX509NamePolicyValidator(p.ctl.getPolicy().getX509()), + p.ctl.newWebhookController(nil, linkedca.Webhook_X509), } return opts, nil diff --git a/authority/provisioner/acme_test.go b/authority/provisioner/acme_test.go index d476ef08..94684ce1 100644 --- a/authority/provisioner/acme_test.go +++ b/authority/provisioner/acme_test.go @@ -269,7 +269,7 @@ func TestACME_AuthorizeSign(t *testing.T) { } } else { if assert.Nil(t, tc.err) && assert.NotNil(t, opts) { - assert.Equals(t, 7, len(opts)) // number of SignOptions returned + assert.Equals(t, 8, len(opts)) // number of SignOptions returned for _, o := range opts { switch v := o.(type) { case *ACME: @@ -288,6 +288,8 @@ func TestACME_AuthorizeSign(t *testing.T) { assert.Equals(t, v.max, tc.p.ctl.Claimer.MaxTLSCertDuration()) case *x509NamePolicyValidator: assert.Equals(t, nil, v.policyEngine) + case *WebhookController: + assert.Len(t, 0, v.webhooks) default: assert.FatalError(t, fmt.Errorf("unexpected sign option of type %T", v)) } diff --git a/authority/provisioner/aws.go b/authority/provisioner/aws.go index caf72142..0560877c 100644 --- a/authority/provisioner/aws.go +++ b/authority/provisioner/aws.go @@ -21,6 +21,7 @@ import ( "go.step.sm/crypto/jose" "go.step.sm/crypto/sshutil" "go.step.sm/crypto/x509util" + "go.step.sm/linkedca" "github.com/smallstep/certificates/errs" ) @@ -484,6 +485,7 @@ func (p *AWS) AuthorizeSign(ctx context.Context, token string) ([]SignOption, er commonNameValidator(payload.Claims.Subject), newValidityValidator(p.ctl.Claimer.MinTLSCertDuration(), p.ctl.Claimer.MaxTLSCertDuration()), newX509NamePolicyValidator(p.ctl.getPolicy().getX509()), + p.ctl.newWebhookController(data, linkedca.Webhook_X509), ), nil } @@ -765,5 +767,7 @@ func (p *AWS) AuthorizeSSHSign(ctx context.Context, token string) ([]SignOption, &sshCertDefaultValidator{}, // Ensure that all principal names are allowed newSSHNamePolicyValidator(p.ctl.getPolicy().getSSHHost(), nil), + // Call webhooks + p.ctl.newWebhookController(data, linkedca.Webhook_SSH), ), nil } diff --git a/authority/provisioner/aws_test.go b/authority/provisioner/aws_test.go index 0ad1eca9..668bc13b 100644 --- a/authority/provisioner/aws_test.go +++ b/authority/provisioner/aws_test.go @@ -642,11 +642,11 @@ func TestAWS_AuthorizeSign(t *testing.T) { code int wantErr bool }{ - {"ok", p1, args{t1, "foo.local"}, 8, http.StatusOK, false}, - {"ok", p2, args{t2, "instance-id"}, 12, http.StatusOK, false}, - {"ok", p2, args{t2Hostname, "ip-127-0-0-1.us-west-1.compute.internal"}, 12, http.StatusOK, false}, - {"ok", p2, args{t2PrivateIP, "127.0.0.1"}, 12, http.StatusOK, false}, - {"ok", p1, args{t4, "instance-id"}, 8, http.StatusOK, false}, + {"ok", p1, args{t1, "foo.local"}, 9, http.StatusOK, false}, + {"ok", p2, args{t2, "instance-id"}, 13, http.StatusOK, false}, + {"ok", p2, args{t2Hostname, "ip-127-0-0-1.us-west-1.compute.internal"}, 13, http.StatusOK, false}, + {"ok", p2, args{t2PrivateIP, "127.0.0.1"}, 13, http.StatusOK, false}, + {"ok", p1, args{t4, "instance-id"}, 9, http.StatusOK, false}, {"fail account", p3, args{token: t3}, 0, http.StatusUnauthorized, true}, {"fail token", p1, args{token: "token"}, 0, http.StatusUnauthorized, true}, {"fail subject", p1, args{token: failSubject}, 0, http.StatusUnauthorized, true}, @@ -701,6 +701,8 @@ func TestAWS_AuthorizeSign(t *testing.T) { assert.Equals(t, []string(v), []string{"ip-127-0-0-1.us-west-1.compute.internal"}) case *x509NamePolicyValidator: assert.Equals(t, nil, v.policyEngine) + case *WebhookController: + assert.Len(t, 0, v.webhooks) default: assert.FatalError(t, fmt.Errorf("unexpected sign option of type %T", v)) } diff --git a/authority/provisioner/azure.go b/authority/provisioner/azure.go index d6f71a89..4b161d9c 100644 --- a/authority/provisioner/azure.go +++ b/authority/provisioner/azure.go @@ -17,6 +17,7 @@ import ( "go.step.sm/crypto/jose" "go.step.sm/crypto/sshutil" "go.step.sm/crypto/x509util" + "go.step.sm/linkedca" "github.com/smallstep/certificates/errs" ) @@ -363,6 +364,7 @@ func (p *Azure) AuthorizeSign(ctx context.Context, token string) ([]SignOption, defaultPublicKeyValidator{}, newValidityValidator(p.ctl.Claimer.MinTLSCertDuration(), p.ctl.Claimer.MaxTLSCertDuration()), newX509NamePolicyValidator(p.ctl.getPolicy().getX509()), + p.ctl.newWebhookController(data, linkedca.Webhook_X509), ), nil } @@ -431,6 +433,8 @@ func (p *Azure) AuthorizeSSHSign(ctx context.Context, token string) ([]SignOptio &sshCertDefaultValidator{}, // Ensure that all principal names are allowed newSSHNamePolicyValidator(p.ctl.getPolicy().getSSHHost(), nil), + // Call webhooks + p.ctl.newWebhookController(data, linkedca.Webhook_SSH), ), nil } diff --git a/authority/provisioner/azure_test.go b/authority/provisioner/azure_test.go index 539a9d16..84f2ebbf 100644 --- a/authority/provisioner/azure_test.go +++ b/authority/provisioner/azure_test.go @@ -474,11 +474,11 @@ func TestAzure_AuthorizeSign(t *testing.T) { code int wantErr bool }{ - {"ok", p1, args{t1}, 7, http.StatusOK, false}, - {"ok", p2, args{t2}, 12, http.StatusOK, false}, - {"ok", p1, args{t11}, 7, http.StatusOK, false}, - {"ok", p5, args{t5}, 7, http.StatusOK, false}, - {"ok", p7, args{t7}, 7, http.StatusOK, false}, + {"ok", p1, args{t1}, 8, http.StatusOK, false}, + {"ok", p2, args{t2}, 13, http.StatusOK, false}, + {"ok", p1, args{t11}, 8, http.StatusOK, false}, + {"ok", p5, args{t5}, 8, http.StatusOK, false}, + {"ok", p7, args{t7}, 8, http.StatusOK, false}, {"fail tenant", p3, args{t3}, 0, http.StatusUnauthorized, true}, {"fail resource group", p4, args{t4}, 0, http.StatusUnauthorized, true}, {"fail subscription", p6, args{t6}, 0, http.StatusUnauthorized, true}, @@ -530,6 +530,8 @@ func TestAzure_AuthorizeSign(t *testing.T) { assert.Equals(t, []string(v), []string{"virtualMachine"}) case *x509NamePolicyValidator: assert.Equals(t, nil, v.policyEngine) + case *WebhookController: + assert.Len(t, 0, v.webhooks) default: assert.FatalError(t, fmt.Errorf("unexpected sign option of type %T", v)) } diff --git a/authority/provisioner/controller.go b/authority/provisioner/controller.go index 063ab50c..7e75c0e4 100644 --- a/authority/provisioner/controller.go +++ b/authority/provisioner/controller.go @@ -10,6 +10,7 @@ import ( "github.com/pkg/errors" "github.com/smallstep/certificates/errs" + "go.step.sm/linkedca" "golang.org/x/crypto/ssh" ) @@ -23,6 +24,8 @@ type Controller struct { AuthorizeRenewFunc AuthorizeRenewFunc AuthorizeSSHRenewFunc AuthorizeSSHRenewFunc policy *policyEngine + webhookClient *http.Client + webhooks []*Webhook } // NewController initializes a new provisioner controller. @@ -43,6 +46,8 @@ func NewController(p Interface, claims *Claims, config Config, options *Options) AuthorizeRenewFunc: config.AuthorizeRenewFunc, AuthorizeSSHRenewFunc: config.AuthorizeSSHRenewFunc, policy: policy, + webhookClient: config.WebhookClient, + webhooks: options.GetWebhooks(), }, nil } @@ -72,6 +77,18 @@ func (c *Controller) AuthorizeSSHRenew(ctx context.Context, cert *ssh.Certificat return DefaultAuthorizeSSHRenew(ctx, c, cert) } +func (c *Controller) newWebhookController(templateData WebhookSetter, certType linkedca.Webhook_CertType) *WebhookController { + client := c.webhookClient + if client == nil { + client = http.DefaultClient + } + return &WebhookController{ + TemplateData: templateData, + client: client, + webhooks: c.webhooks, + } +} + // Identity is the type representing an externally supplied identity that is used // by provisioners to populate certificate fields. type Identity struct { diff --git a/authority/provisioner/gcp.go b/authority/provisioner/gcp.go index 19b731fa..e9b372b2 100644 --- a/authority/provisioner/gcp.go +++ b/authority/provisioner/gcp.go @@ -18,6 +18,7 @@ import ( "go.step.sm/crypto/jose" "go.step.sm/crypto/sshutil" "go.step.sm/crypto/x509util" + "go.step.sm/linkedca" "github.com/smallstep/certificates/errs" ) @@ -272,6 +273,7 @@ func (p *GCP) AuthorizeSign(ctx context.Context, token string) ([]SignOption, er defaultPublicKeyValidator{}, newValidityValidator(p.ctl.Claimer.MinTLSCertDuration(), p.ctl.Claimer.MaxTLSCertDuration()), newX509NamePolicyValidator(p.ctl.getPolicy().getX509()), + p.ctl.newWebhookController(data, linkedca.Webhook_X509), ), nil } @@ -437,5 +439,7 @@ func (p *GCP) AuthorizeSSHSign(ctx context.Context, token string) ([]SignOption, &sshCertDefaultValidator{}, // Ensure that all principal names are allowed newSSHNamePolicyValidator(p.ctl.getPolicy().getSSHHost(), nil), + // Call webhooks + p.ctl.newWebhookController(data, linkedca.Webhook_SSH), ), nil } diff --git a/authority/provisioner/gcp_test.go b/authority/provisioner/gcp_test.go index 0aa12301..7705b44a 100644 --- a/authority/provisioner/gcp_test.go +++ b/authority/provisioner/gcp_test.go @@ -516,9 +516,9 @@ func TestGCP_AuthorizeSign(t *testing.T) { code int wantErr bool }{ - {"ok", p1, args{t1}, 7, http.StatusOK, false}, - {"ok", p2, args{t2}, 12, http.StatusOK, false}, - {"ok", p3, args{t3}, 7, http.StatusOK, false}, + {"ok", p1, args{t1}, 8, http.StatusOK, false}, + {"ok", p2, args{t2}, 13, http.StatusOK, false}, + {"ok", p3, args{t3}, 8, http.StatusOK, false}, {"fail token", p1, args{"token"}, 0, http.StatusUnauthorized, true}, {"fail key", p1, args{failKey}, 0, http.StatusUnauthorized, true}, {"fail iss", p1, args{failIss}, 0, http.StatusUnauthorized, true}, @@ -573,6 +573,8 @@ func TestGCP_AuthorizeSign(t *testing.T) { assert.Equals(t, []string(v), []string{"instance-name.c.project-id.internal", "instance-name.zone.c.project-id.internal"}) case *x509NamePolicyValidator: assert.Equals(t, nil, v.policyEngine) + case *WebhookController: + assert.Len(t, 0, v.webhooks) default: assert.FatalError(t, fmt.Errorf("unexpected sign option of type %T", v)) } diff --git a/authority/provisioner/jwk.go b/authority/provisioner/jwk.go index 5cfb0409..59332996 100644 --- a/authority/provisioner/jwk.go +++ b/authority/provisioner/jwk.go @@ -11,6 +11,7 @@ import ( "go.step.sm/crypto/jose" "go.step.sm/crypto/sshutil" "go.step.sm/crypto/x509util" + "go.step.sm/linkedca" "github.com/smallstep/certificates/errs" ) @@ -194,6 +195,7 @@ func (p *JWK) AuthorizeSign(ctx context.Context, token string) ([]SignOption, er defaultSANsValidator(claims.SANs), newValidityValidator(p.ctl.Claimer.MinTLSCertDuration(), p.ctl.Claimer.MaxTLSCertDuration()), newX509NamePolicyValidator(p.ctl.getPolicy().getX509()), + p.ctl.newWebhookController(data, linkedca.Webhook_X509), }, nil } @@ -278,6 +280,8 @@ func (p *JWK) AuthorizeSSHSign(ctx context.Context, token string) ([]SignOption, &sshCertDefaultValidator{}, // Ensure that all principal names are allowed newSSHNamePolicyValidator(p.ctl.getPolicy().getSSHHost(), p.ctl.getPolicy().getSSHUser()), + // Call webhooks + p.ctl.newWebhookController(data, linkedca.Webhook_SSH), ), nil } diff --git a/authority/provisioner/jwk_test.go b/authority/provisioner/jwk_test.go index c34ce918..19cee4fb 100644 --- a/authority/provisioner/jwk_test.go +++ b/authority/provisioner/jwk_test.go @@ -297,7 +297,7 @@ func TestJWK_AuthorizeSign(t *testing.T) { } } else { if assert.NotNil(t, got) { - assert.Equals(t, 9, len(got)) + assert.Equals(t, 10, len(got)) for _, o := range got { switch v := o.(type) { case *JWK: @@ -319,6 +319,7 @@ func TestJWK_AuthorizeSign(t *testing.T) { assert.Equals(t, []string(v), tt.sans) case *x509NamePolicyValidator: assert.Equals(t, nil, v.policyEngine) + case *WebhookController: default: assert.FatalError(t, fmt.Errorf("unexpected sign option of type %T", v)) } diff --git a/authority/provisioner/k8sSA.go b/authority/provisioner/k8sSA.go index 3d79933a..e970616d 100644 --- a/authority/provisioner/k8sSA.go +++ b/authority/provisioner/k8sSA.go @@ -15,6 +15,7 @@ import ( "go.step.sm/crypto/pemutil" "go.step.sm/crypto/sshutil" "go.step.sm/crypto/x509util" + "go.step.sm/linkedca" "github.com/smallstep/certificates/errs" ) @@ -242,6 +243,7 @@ func (p *K8sSA) AuthorizeSign(ctx context.Context, token string) ([]SignOption, defaultPublicKeyValidator{}, newValidityValidator(p.ctl.Claimer.MinTLSCertDuration(), p.ctl.Claimer.MaxTLSCertDuration()), newX509NamePolicyValidator(p.ctl.getPolicy().getX509()), + p.ctl.newWebhookController(data, linkedca.Webhook_X509), }, nil } @@ -287,6 +289,8 @@ func (p *K8sSA) AuthorizeSSHSign(ctx context.Context, token string) ([]SignOptio &sshCertDefaultValidator{}, // Ensure that all principal names are allowed newSSHNamePolicyValidator(p.ctl.getPolicy().getSSHHost(), p.ctl.getPolicy().getSSHUser()), + // Call webhooks + p.ctl.newWebhookController(data, linkedca.Webhook_SSH), ), nil } diff --git a/authority/provisioner/k8sSA_test.go b/authority/provisioner/k8sSA_test.go index 8cf06e53..48581c2d 100644 --- a/authority/provisioner/k8sSA_test.go +++ b/authority/provisioner/k8sSA_test.go @@ -297,11 +297,13 @@ func TestK8sSA_AuthorizeSign(t *testing.T) { assert.Equals(t, v.max, tc.p.ctl.Claimer.MaxTLSCertDuration()) case *x509NamePolicyValidator: assert.Equals(t, nil, v.policyEngine) + case *WebhookController: + assert.Len(t, 0, v.webhooks) default: assert.FatalError(t, fmt.Errorf("unexpected sign option of type %T", v)) } } - assert.Equals(t, 7, len(opts)) + assert.Equals(t, 8, len(opts)) } } } @@ -368,7 +370,7 @@ func TestK8sSA_AuthorizeSSHSign(t *testing.T) { } else { if assert.Nil(t, tc.err) { if assert.NotNil(t, opts) { - assert.Len(t, 8, opts) + assert.Len(t, 9, opts) for _, o := range opts { switch v := o.(type) { case Interface: @@ -384,6 +386,8 @@ func TestK8sSA_AuthorizeSSHSign(t *testing.T) { case *sshNamePolicyValidator: assert.Equals(t, nil, v.userPolicyEngine) assert.Equals(t, nil, v.hostPolicyEngine) + case *WebhookController: + assert.Len(t, 0, v.webhooks) default: assert.FatalError(t, fmt.Errorf("unexpected sign option of type %T", v)) } diff --git a/authority/provisioner/nebula.go b/authority/provisioner/nebula.go index cde5857c..02762a0a 100644 --- a/authority/provisioner/nebula.go +++ b/authority/provisioner/nebula.go @@ -15,6 +15,7 @@ import ( "go.step.sm/crypto/sshutil" "go.step.sm/crypto/x25519" "go.step.sm/crypto/x509util" + "go.step.sm/linkedca" "golang.org/x/crypto/ssh" "github.com/smallstep/certificates/errs" @@ -164,6 +165,7 @@ func (p *Nebula) AuthorizeSign(ctx context.Context, token string) ([]SignOption, defaultPublicKeyValidator{}, newValidityValidator(p.ctl.Claimer.MinTLSCertDuration(), p.ctl.Claimer.MaxTLSCertDuration()), newX509NamePolicyValidator(p.ctl.getPolicy().getX509()), + p.ctl.newWebhookController(data, linkedca.Webhook_X509), }, nil } @@ -262,6 +264,8 @@ func (p *Nebula) AuthorizeSSHSign(ctx context.Context, token string) ([]SignOpti &sshCertDefaultValidator{}, // Ensure that all principal names are allowed newSSHNamePolicyValidator(p.ctl.getPolicy().getSSHHost(), nil), + // Call webhooks + p.ctl.newWebhookController(data, linkedca.Webhook_SSH), ), nil } diff --git a/authority/provisioner/oidc.go b/authority/provisioner/oidc.go index 5463f20c..3840a4a8 100644 --- a/authority/provisioner/oidc.go +++ b/authority/provisioner/oidc.go @@ -16,6 +16,7 @@ import ( "go.step.sm/crypto/jose" "go.step.sm/crypto/sshutil" "go.step.sm/crypto/x509util" + "go.step.sm/linkedca" "github.com/smallstep/certificates/errs" ) @@ -356,6 +357,8 @@ func (o *OIDC) AuthorizeSign(ctx context.Context, token string) ([]SignOption, e defaultPublicKeyValidator{}, newValidityValidator(o.ctl.Claimer.MinTLSCertDuration(), o.ctl.Claimer.MaxTLSCertDuration()), newX509NamePolicyValidator(o.ctl.getPolicy().getX509()), + // webhooks + o.ctl.newWebhookController(data, linkedca.Webhook_X509), }, nil } @@ -460,6 +463,8 @@ func (o *OIDC) AuthorizeSSHSign(ctx context.Context, token string) ([]SignOption &sshCertDefaultValidator{}, // Ensure that all principal names are allowed newSSHNamePolicyValidator(o.ctl.getPolicy().getSSHHost(), o.ctl.getPolicy().getSSHUser()), + // Call webhooks + o.ctl.newWebhookController(data, linkedca.Webhook_SSH), ), nil } diff --git a/authority/provisioner/oidc_test.go b/authority/provisioner/oidc_test.go index 3aa969e3..083799f6 100644 --- a/authority/provisioner/oidc_test.go +++ b/authority/provisioner/oidc_test.go @@ -323,7 +323,7 @@ func TestOIDC_AuthorizeSign(t *testing.T) { assert.Equals(t, sc.StatusCode(), tt.code) assert.Nil(t, got) } else if assert.NotNil(t, got) { - assert.Equals(t, 7, len(got)) + assert.Equals(t, 8, len(got)) for _, o := range got { switch v := o.(type) { case *OIDC: @@ -343,6 +343,8 @@ func TestOIDC_AuthorizeSign(t *testing.T) { assert.Equals(t, string(v), "name@smallstep.com") case *x509NamePolicyValidator: assert.Equals(t, nil, v.policyEngine) + case *WebhookController: + assert.Len(t, 0, v.webhooks) default: assert.FatalError(t, fmt.Errorf("unexpected sign option of type %T", v)) } diff --git a/authority/provisioner/options.go b/authority/provisioner/options.go index f5c919b4..702666a4 100644 --- a/authority/provisioner/options.go +++ b/authority/provisioner/options.go @@ -29,6 +29,9 @@ func (fn certificateOptionsFunc) Options(so SignOptions) []x509util.Option { type Options struct { X509 *X509Options `json:"x509,omitempty"` SSH *SSHOptions `json:"ssh,omitempty"` + + // Webhooks is a list of webhooks that can augment template data + Webhooks []*Webhook `json:"webhooks,omitempty"` } // GetX509Options returns the X.509 options. @@ -47,6 +50,14 @@ func (o *Options) GetSSHOptions() *SSHOptions { return o.SSH } +// GetWebhooks returns the webhooks options. +func (o *Options) GetWebhooks() []*Webhook { + if o == nil { + return nil + } + return o.Webhooks +} + // X509Options contains specific options for X.509 certificates. type X509Options struct { // Template contains a X.509 certificate template. It can be a JSON template diff --git a/authority/provisioner/options_test.go b/authority/provisioner/options_test.go index aaf4b36c..405ec8b7 100644 --- a/authority/provisioner/options_test.go +++ b/authority/provisioner/options_test.go @@ -68,6 +68,36 @@ func TestOptions_GetSSHOptions(t *testing.T) { } } +func TestOptions_GetWebhooks(t *testing.T) { + type fields struct { + o *Options + } + tests := []struct { + name string + fields fields + want []*Webhook + }{ + {"ok", fields{&Options{Webhooks: []*Webhook{ + {Name: "foo"}, + {Name: "bar"}, + }}}, + []*Webhook{ + {Name: "foo"}, + {Name: "bar"}, + }, + }, + {"nil", fields{&Options{}}, nil}, + {"nilOptions", fields{nil}, nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.fields.o.GetWebhooks(); !reflect.DeepEqual(got, tt.want) { + t.Errorf("Options.GetWebhooks() = %v, want %v", got, tt.want) + } + }) + } +} + func TestProvisionerX509Options_HasTemplate(t *testing.T) { type fields struct { Template string diff --git a/authority/provisioner/provisioner.go b/authority/provisioner/provisioner.go index 29d44c1c..9d65d585 100644 --- a/authority/provisioner/provisioner.go +++ b/authority/provisioner/provisioner.go @@ -5,6 +5,7 @@ import ( "crypto/x509" "encoding/json" stderrors "errors" + "net/http" "net/url" "strings" @@ -222,6 +223,8 @@ type Config struct { // AuthorizeSSHRenewFunc is a function that returns nil if a given SSH // certificate can be renewed. AuthorizeSSHRenewFunc AuthorizeSSHRenewFunc + // WebhookClient is an http client to use in webhook request + WebhookClient *http.Client } type provisioner struct { diff --git a/authority/provisioner/scep.go b/authority/provisioner/scep.go index c49c993e..0f27b206 100644 --- a/authority/provisioner/scep.go +++ b/authority/provisioner/scep.go @@ -5,6 +5,7 @@ import ( "time" "github.com/pkg/errors" + "go.step.sm/linkedca" ) // SCEP is the SCEP provisioner type, an entity that can authorize the @@ -128,6 +129,7 @@ func (s *SCEP) AuthorizeSign(ctx context.Context, token string) ([]SignOption, e newPublicKeyMinimumLengthValidator(s.MinimumPublicKeyLength), newValidityValidator(s.ctl.Claimer.MinTLSCertDuration(), s.ctl.Claimer.MaxTLSCertDuration()), newX509NamePolicyValidator(s.ctl.getPolicy().getX509()), + s.ctl.newWebhookController(nil, linkedca.Webhook_X509), }, nil } diff --git a/authority/provisioner/ssh_test.go b/authority/provisioner/ssh_test.go index 3fd97f9d..6ad71459 100644 --- a/authority/provisioner/ssh_test.go +++ b/authority/provisioner/ssh_test.go @@ -69,6 +69,8 @@ func signSSHCertificate(key crypto.PublicKey, opts SignSSHOptions, signOpts []Si if err := o.Valid(opts); err != nil { return nil, err } + // call webhooks + case *WebhookController: default: return nil, fmt.Errorf("signSSH: invalid extra option type %T", o) } diff --git a/authority/provisioner/testdata/certs/foo.crt b/authority/provisioner/testdata/certs/foo.crt new file mode 100644 index 00000000..eb06f218 --- /dev/null +++ b/authority/provisioner/testdata/certs/foo.crt @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICIDCCAcagAwIBAgIQTL7pKDl8mFzRziotXbgjEjAKBggqhkjOPQQDAjAnMSUw +IwYDVQQDExxFeGFtcGxlIEluYy4gSW50ZXJtZWRpYXRlIENBMB4XDTE5MDMyMjIy +MjkyOVoXDTE5MDMyMzIyMjkyOVowHDEaMBgGA1UEAxMRZm9vLnNtYWxsc3RlcC5j +b20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQbptfDonFaeUPiTr52wl9r3dcz +greolwDRmsgyFgnr1EuKH56WRcgH1gjfL0pybFlO3PdgBukR4u+sveq343OAo4He +MIHbMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUH +AwIwHQYDVR0OBBYEFP9pHiVlsx5mr4L2QirOb1G9Mo4jMB8GA1UdIwQYMBaAFKEe +9IdMyaHdURMjoJce7FN9HC9wMBwGA1UdEQQVMBOCEWZvby5zbWFsbHN0ZXAuY29t +MEwGDCsGAQQBgqRkxihAAQQ8MDoCAQEECHN0ZXAtY2xpBCs0VUVMSng4ZTBhUzlt +MENIM2ZaMEVCN0Q1YVVQSUNiNzU5ekFMSEZlanZjMAoGCCqGSM49BAMCA0gAMEUC +IDxtNo1BX/4Sbf/+k1n+v//kh8ETr3clPvhjcyfvBIGTAiEAiT0kvbkPdCCnmHIw +lhpgBwT5YReZzBwIYXyKyJXc07M= +-----END CERTIFICATE----- diff --git a/authority/provisioner/testdata/secrets/foo.key b/authority/provisioner/testdata/secrets/foo.key new file mode 100644 index 00000000..b1b63324 --- /dev/null +++ b/authority/provisioner/testdata/secrets/foo.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIJmnxm3N/ahRA2PWeZhRGJUKPU1lI44WcE4P1bynIim6oAoGCCqGSM49 +AwEHoUQDQgAEG6bXw6JxWnlD4k6+dsJfa93XM4K3qJcA0ZrIMhYJ69RLih+elkXI +B9YI3y9KcmxZTtz3YAbpEeLvrL3qt+NzgA== +-----END EC PRIVATE KEY----- diff --git a/authority/provisioner/webhook.go b/authority/provisioner/webhook.go new file mode 100644 index 00000000..ea02da35 --- /dev/null +++ b/authority/provisioner/webhook.go @@ -0,0 +1,209 @@ +package provisioner + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "net/http" + "text/template" + "time" + + "github.com/pkg/errors" + "github.com/smallstep/certificates/templates" + "github.com/smallstep/certificates/webhook" + "go.step.sm/linkedca" +) + +var ErrWebhookDenied = errors.New("webhook server did not allow request") + +type WebhookSetter interface { + SetWebhook(string, any) +} + +type WebhookController struct { + client *http.Client + webhooks []*Webhook + certType linkedca.Webhook_CertType + TemplateData WebhookSetter +} + +// Enrich fetches data from remote servers and adds returned data to the +// templateData +func (wc *WebhookController) Enrich(req *webhook.RequestBody) error { + if wc == nil { + return nil + } + for _, wh := range wc.webhooks { + if wh.Kind != linkedca.Webhook_ENRICHING.String() { + continue + } + if !wc.isCertTypeOK(wh) { + continue + } + resp, err := wh.Do(wc.client, req, wc.TemplateData) + if err != nil { + return err + } + if !resp.Allow { + return ErrWebhookDenied + } + wc.TemplateData.SetWebhook(wh.Name, resp.Data) + } + return nil +} + +// Authorize checks that all remote servers allow the request +func (wc *WebhookController) Authorize(req *webhook.RequestBody) error { + if wc == nil { + return nil + } + for _, wh := range wc.webhooks { + if wh.Kind != linkedca.Webhook_AUTHORIZING.String() { + continue + } + if !wc.isCertTypeOK(wh) { + continue + } + resp, err := wh.Do(wc.client, req, wc.TemplateData) + if err != nil { + return err + } + if !resp.Allow { + return ErrWebhookDenied + } + } + return nil +} + +func (wc *WebhookController) isCertTypeOK(wh *Webhook) bool { + if wc.certType == linkedca.Webhook_ALL { + return true + } + if wh.CertType == linkedca.Webhook_ALL.String() || wh.CertType == "" { + return true + } + return wc.certType.String() == wh.CertType +} + +type Webhook struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Kind string `json:"kind"` + DisableTLSClientAuth bool `json:"disableTLSClientAuth,omitempty"` + CertType string `json:"certType"` + Secret string `json:"-"` + BearerToken string `json:"-"` + BasicAuth struct { + Username string + Password string + } `json:"-"` +} + +func (w *Webhook) Do(client *http.Client, reqBody *webhook.RequestBody, data any) (*webhook.ResponseBody, error) { + tmpl, err := template.New("url").Funcs(templates.StepFuncMap()).Parse(w.URL) + if err != nil { + return nil, err + } + buf := &bytes.Buffer{} + if err := tmpl.Execute(buf, data); err != nil { + return nil, err + } + url := buf.String() + + /* + Sending the token to the webhook server is a security risk. A K8sSA + token can be reused multiple times. The webhook can misuse it to get + fake certificates. A webhook can misuse any other token to get its own + certificate before responding. + switch tmpl := data.(type) { + case x509util.TemplateData: + reqBody.Token = tmpl[x509util.TokenKey] + case sshutil.TemplateData: + reqBody.Token = tmpl[sshutil.TokenKey] + } + */ + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + + reqBody.Timestamp = time.Now() + + reqBytes, err := json.Marshal(reqBody) + if err != nil { + return nil, err + } + + retries := 1 +retry: + + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(reqBytes)) + if err != nil { + return nil, err + } + + secret, err := base64.StdEncoding.DecodeString(w.Secret) + if err != nil { + return nil, err + } + sig := hmac.New(sha256.New, secret).Sum(reqBytes) + req.Header.Set("X-Smallstep-Signature", hex.EncodeToString(sig)) + req.Header.Set("X-Smallstep-Webhook-ID", w.ID) + + if w.BearerToken != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", w.BearerToken)) + } else if w.BasicAuth.Username != "" || w.BasicAuth.Password != "" { + req.SetBasicAuth(w.BasicAuth.Username, w.BasicAuth.Password) + } + + if w.DisableTLSClientAuth { + transport, ok := client.Transport.(*http.Transport) + if !ok { + return nil, errors.New("client transport is not a *http.Transport") + } + transport = transport.Clone() + tlsConfig := transport.TLSClientConfig.Clone() + tlsConfig.GetClientCertificate = nil + tlsConfig.Certificates = nil + transport.TLSClientConfig = tlsConfig + client = &http.Client{ + Transport: transport, + } + } + resp, err := client.Do(req) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return nil, err + } else if retries > 0 { + retries-- + time.Sleep(time.Second) + goto retry + } + return nil, err + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.Printf("Failed to close body of response from %s", w.URL) + } + }() + if resp.StatusCode >= 500 && retries > 0 { + retries-- + time.Sleep(time.Second) + goto retry + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("Webhook server responded with %d", resp.StatusCode) + } + + respBody := &webhook.ResponseBody{} + if err := json.NewDecoder(resp.Body).Decode(respBody); err != nil { + return nil, err + } + + return respBody, nil +} diff --git a/authority/provisioner/webhook_test.go b/authority/provisioner/webhook_test.go new file mode 100644 index 00000000..a7895638 --- /dev/null +++ b/authority/provisioner/webhook_test.go @@ -0,0 +1,473 @@ +package provisioner + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/tls" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/pkg/errors" + "github.com/smallstep/assert" + "github.com/smallstep/certificates/webhook" + "go.step.sm/crypto/x509util" + "go.step.sm/linkedca" +) + +func TestWebhookController_isCertTypeOK(t *testing.T) { + type test struct { + wc *WebhookController + wh *Webhook + want bool + } + tests := map[string]test{ + "all/all": { + wc: &WebhookController{certType: linkedca.Webhook_ALL}, + wh: &Webhook{CertType: linkedca.Webhook_ALL.String()}, + want: true, + }, + "all/x509": { + wc: &WebhookController{certType: linkedca.Webhook_ALL}, + wh: &Webhook{CertType: linkedca.Webhook_X509.String()}, + want: true, + }, + "all/ssh": { + wc: &WebhookController{certType: linkedca.Webhook_ALL}, + wh: &Webhook{CertType: linkedca.Webhook_SSH.String()}, + want: true, + }, + `all/""`: { + wc: &WebhookController{certType: linkedca.Webhook_ALL}, + wh: &Webhook{}, + want: true, + }, + "x509/all": { + wc: &WebhookController{certType: linkedca.Webhook_X509}, + wh: &Webhook{CertType: linkedca.Webhook_ALL.String()}, + want: true, + }, + "x509/x509": { + wc: &WebhookController{certType: linkedca.Webhook_X509}, + wh: &Webhook{CertType: linkedca.Webhook_X509.String()}, + want: true, + }, + "x509/ssh": { + wc: &WebhookController{certType: linkedca.Webhook_X509}, + wh: &Webhook{CertType: linkedca.Webhook_SSH.String()}, + want: false, + }, + `x509/""`: { + wc: &WebhookController{certType: linkedca.Webhook_X509}, + wh: &Webhook{}, + want: true, + }, + "ssh/all": { + wc: &WebhookController{certType: linkedca.Webhook_SSH}, + wh: &Webhook{CertType: linkedca.Webhook_ALL.String()}, + want: true, + }, + "ssh/x509": { + wc: &WebhookController{certType: linkedca.Webhook_SSH}, + wh: &Webhook{CertType: linkedca.Webhook_X509.String()}, + want: false, + }, + "ssh/ssh": { + wc: &WebhookController{certType: linkedca.Webhook_SSH}, + wh: &Webhook{CertType: linkedca.Webhook_SSH.String()}, + want: true, + }, + `ssh/""`: { + wc: &WebhookController{certType: linkedca.Webhook_SSH}, + wh: &Webhook{}, + want: true, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + assert.Equals(t, test.want, test.wc.isCertTypeOK(test.wh)) + }) + } +} + +func TestWebhookController_Enrich(t *testing.T) { + type test struct { + ctl *WebhookController + req *webhook.RequestBody + responses []*webhook.ResponseBody + expectErr bool + expectTemplateData any + } + tests := map[string]test{ + "ok/no enriching webhooks": { + ctl: &WebhookController{ + client: http.DefaultClient, + webhooks: []*Webhook{{Name: "people", Kind: "AUTHORIZING"}}, + TemplateData: nil, + }, + req: &webhook.RequestBody{}, + responses: nil, + expectErr: false, + expectTemplateData: nil, + }, + "ok/one webhook": { + ctl: &WebhookController{ + client: http.DefaultClient, + webhooks: []*Webhook{{Name: "people", Kind: "ENRICHING"}}, + TemplateData: x509util.TemplateData{}, + }, + req: &webhook.RequestBody{}, + responses: []*webhook.ResponseBody{{Allow: true, Data: map[string]any{"role": "bar"}}}, + expectErr: false, + expectTemplateData: x509util.TemplateData{"Webhooks": map[string]any{"people": map[string]any{"role": "bar"}}}, + }, + "ok/two webhooks": { + ctl: &WebhookController{ + client: http.DefaultClient, + webhooks: []*Webhook{ + {Name: "people", Kind: "ENRICHING"}, + {Name: "devices", Kind: "ENRICHING"}, + }, + TemplateData: x509util.TemplateData{}, + }, + req: &webhook.RequestBody{}, + responses: []*webhook.ResponseBody{ + {Allow: true, Data: map[string]any{"role": "bar"}}, + {Allow: true, Data: map[string]any{"serial": "123"}}, + }, + expectErr: false, + expectTemplateData: x509util.TemplateData{ + "Webhooks": map[string]any{ + "devices": map[string]any{"serial": "123"}, + "people": map[string]any{"role": "bar"}, + }, + }, + }, + "ok/x509 only": { + ctl: &WebhookController{ + client: http.DefaultClient, + webhooks: []*Webhook{ + {Name: "people", Kind: "ENRICHING", CertType: linkedca.Webhook_SSH.String()}, + {Name: "devices", Kind: "ENRICHING"}, + }, + TemplateData: x509util.TemplateData{}, + certType: linkedca.Webhook_X509, + }, + req: &webhook.RequestBody{}, + responses: []*webhook.ResponseBody{ + {Allow: true, Data: map[string]any{"role": "bar"}}, + {Allow: true, Data: map[string]any{"serial": "123"}}, + }, + expectErr: false, + expectTemplateData: x509util.TemplateData{ + "Webhooks": map[string]any{ + "devices": map[string]any{"serial": "123"}, + }, + }, + }, + "deny": { + ctl: &WebhookController{ + client: http.DefaultClient, + webhooks: []*Webhook{{Name: "people", Kind: "ENRICHING"}}, + TemplateData: x509util.TemplateData{}, + }, + req: &webhook.RequestBody{}, + responses: []*webhook.ResponseBody{{Allow: false}}, + expectErr: true, + expectTemplateData: x509util.TemplateData{}, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + for i, wh := range test.ctl.webhooks { + var j = i + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := json.NewEncoder(w).Encode(test.responses[j]) + assert.FatalError(t, err) + })) + // nolint: gocritic // defer in loop isn't a memory leak + defer ts.Close() + wh.URL = ts.URL + } + + err := test.ctl.Enrich(test.req) + if (err != nil) != test.expectErr { + t.Fatalf("Got err %v, want %v", err, test.expectErr) + } + assert.Equals(t, test.expectTemplateData, test.ctl.TemplateData) + }) + } +} + +func TestWebhookController_Authorize(t *testing.T) { + type test struct { + ctl *WebhookController + req *webhook.RequestBody + responses []*webhook.ResponseBody + expectErr bool + } + tests := map[string]test{ + "ok/no enriching webhooks": { + ctl: &WebhookController{ + client: http.DefaultClient, + webhooks: []*Webhook{{Name: "people", Kind: "ENRICHING"}}, + }, + req: &webhook.RequestBody{}, + responses: nil, + expectErr: false, + }, + "ok": { + ctl: &WebhookController{ + client: http.DefaultClient, + webhooks: []*Webhook{{Name: "people", Kind: "AUTHORIZING"}}, + }, + req: &webhook.RequestBody{}, + responses: []*webhook.ResponseBody{{Allow: true}}, + expectErr: false, + }, + "ok/ssh only": { + ctl: &WebhookController{ + client: http.DefaultClient, + webhooks: []*Webhook{{Name: "people", Kind: "AUTHORIZING", CertType: linkedca.Webhook_X509.String()}}, + certType: linkedca.Webhook_SSH, + }, + req: &webhook.RequestBody{}, + responses: []*webhook.ResponseBody{{Allow: false}}, + expectErr: false, + }, + "deny": { + ctl: &WebhookController{ + client: http.DefaultClient, + webhooks: []*Webhook{{Name: "people", Kind: "AUTHORIZING"}}, + }, + req: &webhook.RequestBody{}, + responses: []*webhook.ResponseBody{{Allow: false}}, + expectErr: true, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + for i, wh := range test.ctl.webhooks { + var j = i + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := json.NewEncoder(w).Encode(test.responses[j]) + assert.FatalError(t, err) + })) + // nolint: gocritic // defer in loop isn't a memory leak + defer ts.Close() + wh.URL = ts.URL + } + + err := test.ctl.Authorize(test.req) + if (err != nil) != test.expectErr { + t.Fatalf("Got err %v, want %v", err, test.expectErr) + } + }) + } +} + +func TestWebhook_Do(t *testing.T) { + csr := parseCertificateRequest(t, "testdata/certs/ecdsa.csr") + type test struct { + webhook Webhook + dataArg any + webhookResponse webhook.ResponseBody + expectPath string + errStatusCode int + serverErrMsg string + expectErr error + // expectToken any + } + tests := map[string]test{ + "ok": { + webhook: Webhook{ + ID: "abc123", + Secret: "c2VjcmV0Cg==", + }, + webhookResponse: webhook.ResponseBody{ + Data: map[string]interface{}{"role": "dba"}, + }, + }, + "ok/bearer": { + webhook: Webhook{ + ID: "abc123", + Secret: "c2VjcmV0Cg==", + BearerToken: "mytoken", + }, + webhookResponse: webhook.ResponseBody{ + Data: map[string]interface{}{"role": "dba"}, + }, + }, + "ok/basic": { + webhook: Webhook{ + ID: "abc123", + Secret: "c2VjcmV0Cg==", + BasicAuth: struct { + Username string + Password string + }{ + Username: "myuser", + Password: "mypass", + }, + }, + webhookResponse: webhook.ResponseBody{ + Data: map[string]interface{}{"role": "dba"}, + }, + }, + "ok/templated-url": { + webhook: Webhook{ + ID: "abc123", + // scheme, host, port will come from test server + URL: "/users/{{ .username }}?region={{ .region }}", + Secret: "c2VjcmV0Cg==", + }, + dataArg: map[string]interface{}{"username": "areed", "region": "central"}, + webhookResponse: webhook.ResponseBody{ + Data: map[string]interface{}{"role": "dba"}, + }, + expectPath: "/users/areed?region=central", + }, + /* + "ok/token from ssh template": { + webhook: Webhook{ + ID: "abc123", + Secret: "c2VjcmV0Cg==", + }, + webhookResponse: webhook.ResponseBody{ + Data: map[string]interface{}{"role": "dba"}, + }, + dataArg: sshutil.TemplateData{sshutil.TokenKey: "token"}, + expectToken: "token", + }, + "ok/token from x509 template": { + webhook: Webhook{ + ID: "abc123", + Secret: "c2VjcmV0Cg==", + }, + webhookResponse: webhook.ResponseBody{ + Data: map[string]interface{}{"role": "dba"}, + }, + dataArg: x509util.TemplateData{sshutil.TokenKey: "token"}, + expectToken: "token", + }, + */ + "ok/allow": { + webhook: Webhook{ + ID: "abc123", + Secret: "c2VjcmV0Cg==", + }, + webhookResponse: webhook.ResponseBody{ + Allow: true, + }, + }, + "fail/404": { + webhook: Webhook{ + ID: "abc123", + Secret: "c2VjcmV0Cg==", + }, + webhookResponse: webhook.ResponseBody{ + Data: map[string]interface{}{"role": "dba"}, + }, + errStatusCode: 404, + serverErrMsg: "item not found", + expectErr: errors.New("Webhook server responded with 404"), + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.Header.Get("X-Smallstep-Webhook-ID") + assert.Equals(t, tc.webhook.ID, id) + + sig, err := hex.DecodeString(r.Header.Get("X-Smallstep-Signature")) + assert.FatalError(t, err) + + body, err := io.ReadAll(r.Body) + assert.FatalError(t, err) + + secret, err := base64.StdEncoding.DecodeString(tc.webhook.Secret) + assert.FatalError(t, err) + mac := hmac.New(sha256.New, secret).Sum(body) + assert.True(t, hmac.Equal(sig, mac)) + + switch { + case tc.webhook.BearerToken != "": + ah := fmt.Sprintf("Bearer %s", tc.webhook.BearerToken) + assert.Equals(t, ah, r.Header.Get("Authorization")) + case tc.webhook.BasicAuth.Username != "" || tc.webhook.BasicAuth.Password != "": + whReq, err := http.NewRequest("", "", http.NoBody) + assert.FatalError(t, err) + whReq.SetBasicAuth(tc.webhook.BasicAuth.Username, tc.webhook.BasicAuth.Password) + ah := whReq.Header.Get("Authorization") + assert.Equals(t, ah, whReq.Header.Get("Authorization")) + default: + assert.Equals(t, "", r.Header.Get("Authorization")) + } + + if tc.expectPath != "" { + assert.Equals(t, tc.expectPath, r.URL.Path+"?"+r.URL.RawQuery) + } + + if tc.errStatusCode != 0 { + http.Error(w, tc.serverErrMsg, tc.errStatusCode) + return + } + + reqBody := new(webhook.RequestBody) + err = json.Unmarshal(body, reqBody) + assert.FatalError(t, err) + // assert.Equals(t, tc.expectToken, reqBody.Token) + + err = json.NewEncoder(w).Encode(tc.webhookResponse) + assert.FatalError(t, err) + })) + defer ts.Close() + + tc.webhook.URL = ts.URL + tc.webhook.URL + + reqBody, err := webhook.NewRequestBody(webhook.WithX509CertificateRequest(csr)) + assert.FatalError(t, err) + got, err := tc.webhook.Do(http.DefaultClient, reqBody, tc.dataArg) + if tc.expectErr != nil { + assert.Equals(t, tc.expectErr.Error(), err.Error()) + return + } + assert.FatalError(t, err) + + assert.Equals(t, got, &tc.webhookResponse) + }) + } + + t.Run("disableTLSClientAuth", func(t *testing.T) { + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("{}")) + })) + ts.TLS.ClientAuth = tls.RequireAnyClientCert + wh := Webhook{ + URL: ts.URL, + } + cert, err := tls.LoadX509KeyPair("testdata/certs/foo.crt", "testdata/secrets/foo.key") + assert.FatalError(t, err) + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: true, + Certificates: []tls.Certificate{cert}, + } + client := &http.Client{ + Transport: transport, + } + reqBody, err := webhook.NewRequestBody(webhook.WithX509CertificateRequest(csr)) + assert.FatalError(t, err) + _, err = wh.Do(client, reqBody, nil) + assert.FatalError(t, err) + + wh.DisableTLSClientAuth = true + _, err = wh.Do(client, reqBody, nil) + assert.Error(t, err) + }) +} diff --git a/authority/provisioner/x5c.go b/authority/provisioner/x5c.go index 9f9a0e4e..e60533b7 100644 --- a/authority/provisioner/x5c.go +++ b/authority/provisioner/x5c.go @@ -12,6 +12,7 @@ import ( "go.step.sm/crypto/jose" "go.step.sm/crypto/sshutil" "go.step.sm/crypto/x509util" + "go.step.sm/linkedca" "github.com/smallstep/certificates/errs" ) @@ -245,6 +246,7 @@ func (p *X5C) AuthorizeSign(ctx context.Context, token string) ([]SignOption, er defaultPublicKeyValidator{}, newValidityValidator(p.ctl.Claimer.MinTLSCertDuration(), p.ctl.Claimer.MaxTLSCertDuration()), newX509NamePolicyValidator(p.ctl.getPolicy().getX509()), + p.ctl.newWebhookController(data, linkedca.Webhook_X509), }, nil } @@ -332,5 +334,7 @@ func (p *X5C) AuthorizeSSHSign(ctx context.Context, token string) ([]SignOption, &sshCertDefaultValidator{}, // Ensure that all principal names are allowed newSSHNamePolicyValidator(p.ctl.getPolicy().getSSHHost(), p.ctl.getPolicy().getSSHUser()), + // Call webhooks + p.ctl.newWebhookController(data, linkedca.Webhook_SSH), ), nil } diff --git a/authority/provisioner/x5c_test.go b/authority/provisioner/x5c_test.go index eb4f7def..7cb8593f 100644 --- a/authority/provisioner/x5c_test.go +++ b/authority/provisioner/x5c_test.go @@ -468,7 +468,7 @@ func TestX5C_AuthorizeSign(t *testing.T) { } else { if assert.Nil(t, tc.err) { if assert.NotNil(t, opts) { - assert.Equals(t, 9, len(opts)) + assert.Equals(t, 10, len(opts)) for _, o := range opts { switch v := o.(type) { case *X5C: @@ -493,6 +493,8 @@ func TestX5C_AuthorizeSign(t *testing.T) { assert.Equals(t, v.max, tc.p.ctl.Claimer.MaxTLSCertDuration()) case *x509NamePolicyValidator: assert.Equals(t, nil, v.policyEngine) + case *WebhookController: + assert.Len(t, 0, v.webhooks) default: assert.FatalError(t, fmt.Errorf("unexpected sign option of type %T", v)) } @@ -794,15 +796,17 @@ func TestX5C_AuthorizeSSHSign(t *testing.T) { assert.Equals(t, nil, v.userPolicyEngine) assert.Equals(t, nil, v.hostPolicyEngine) case *sshDefaultPublicKeyValidator, *sshCertDefaultValidator, sshCertificateOptionsFunc: + case *WebhookController: + assert.Len(t, 0, v.webhooks) default: assert.FatalError(t, fmt.Errorf("unexpected sign option of type %T", v)) } tot++ } if len(tc.claims.Step.SSH.CertType) > 0 { - assert.Equals(t, tot, 11) + assert.Equals(t, tot, 12) } else { - assert.Equals(t, tot, 9) + assert.Equals(t, tot, 10) } } } diff --git a/authority/provisioners.go b/authority/provisioners.go index b98b1811..33694cf9 100644 --- a/authority/provisioners.go +++ b/authority/provisioners.go @@ -144,6 +144,7 @@ func (a *Authority) generateProvisionerConfig(ctx context.Context) (provisioner. GetIdentityFunc: a.getIdentityFunc, AuthorizeRenewFunc: a.authorizeRenewFunc, AuthorizeSSHRenewFunc: a.authorizeSSHRenewFunc, + WebhookClient: a.webhookClient, }, nil } @@ -493,9 +494,63 @@ func optionsToCertificates(p *linkedca.Provisioner) *provisioner.Options { } } } + for _, wh := range p.Webhooks { + whCert := webhookToCertificates(wh) + ops.Webhooks = append(ops.Webhooks, whCert) + } return ops } +func webhookToCertificates(wh *linkedca.Webhook) *provisioner.Webhook { + pwh := &provisioner.Webhook{ + ID: wh.Id, + Name: wh.Name, + URL: wh.Url, + Kind: wh.Kind.String(), + Secret: wh.Secret, + DisableTLSClientAuth: wh.DisableTlsClientAuth, + CertType: wh.CertType.String(), + } + + switch a := wh.GetAuth().(type) { + case *linkedca.Webhook_BearerToken: + pwh.BearerToken = a.BearerToken.BearerToken + case *linkedca.Webhook_BasicAuth: + pwh.BasicAuth.Username = a.BasicAuth.Username + pwh.BasicAuth.Password = a.BasicAuth.Password + } + + return pwh +} + +func provisionerWebhookToLinkedca(pwh *provisioner.Webhook) *linkedca.Webhook { + lwh := &linkedca.Webhook{ + Id: pwh.ID, + Name: pwh.Name, + Url: pwh.URL, + Kind: linkedca.Webhook_Kind(linkedca.Webhook_Kind_value[pwh.Kind]), + Secret: pwh.Secret, + DisableTlsClientAuth: pwh.DisableTLSClientAuth, + CertType: linkedca.Webhook_CertType(linkedca.Webhook_CertType_value[pwh.CertType]), + } + if pwh.BearerToken != "" { + lwh.Auth = &linkedca.Webhook_BearerToken{ + BearerToken: &linkedca.BearerToken{ + BearerToken: pwh.BearerToken, + }, + } + } else if pwh.BasicAuth.Username != "" || pwh.BasicAuth.Password != "" { + lwh.Auth = &linkedca.Webhook_BasicAuth{ + BasicAuth: &linkedca.BasicAuth{ + Username: pwh.BasicAuth.Username, + Password: pwh.BasicAuth.Password, + }, + } + } + + return lwh +} + func durationsToCertificates(d *linkedca.Durations) (min, max, def *provisioner.Duration, err error) { if len(d.Min) > 0 { min, err = provisioner.NewDuration(d.Min) @@ -621,12 +676,12 @@ func claimsToLinkedca(c *provisioner.Claims) *linkedca.Claims { return lc } -func provisionerOptionsToLinkedca(p *provisioner.Options) (*linkedca.Template, *linkedca.Template, error) { +func provisionerOptionsToLinkedca(p *provisioner.Options) (*linkedca.Template, *linkedca.Template, []*linkedca.Webhook, error) { var err error var x509Template, sshTemplate *linkedca.Template if p == nil { - return nil, nil, nil + return nil, nil, nil, nil } if p.X509 != nil && p.X509.HasTemplate() { @@ -640,7 +695,7 @@ func provisionerOptionsToLinkedca(p *provisioner.Options) (*linkedca.Template, * } else if p.X509.TemplateFile != "" { filename := step.Abs(p.X509.TemplateFile) if x509Template.Template, err = os.ReadFile(filename); err != nil { - return nil, nil, errors.Wrap(err, "error reading x509 template") + return nil, nil, nil, errors.Wrap(err, "error reading x509 template") } } } @@ -656,12 +711,17 @@ func provisionerOptionsToLinkedca(p *provisioner.Options) (*linkedca.Template, * } else if p.SSH.TemplateFile != "" { filename := step.Abs(p.SSH.TemplateFile) if sshTemplate.Template, err = os.ReadFile(filename); err != nil { - return nil, nil, errors.Wrap(err, "error reading ssh template") + return nil, nil, nil, errors.Wrap(err, "error reading ssh template") } } } - return x509Template, sshTemplate, nil + var webhooks []*linkedca.Webhook + for _, pwh := range p.Webhooks { + webhooks = append(webhooks, provisionerWebhookToLinkedca(pwh)) + } + + return x509Template, sshTemplate, webhooks, nil } func provisionerPEMToLinkedca(b []byte) [][]byte { @@ -879,7 +939,7 @@ func ProvisionerToCertificates(p *linkedca.Provisioner) (provisioner.Interface, func ProvisionerToLinkedca(p provisioner.Interface) (*linkedca.Provisioner, error) { switch p := p.(type) { case *provisioner.JWK: - x509Template, sshTemplate, err := provisionerOptionsToLinkedca(p.Options) + x509Template, sshTemplate, webhooks, err := provisionerOptionsToLinkedca(p.Options) if err != nil { return nil, err } @@ -902,9 +962,10 @@ func ProvisionerToLinkedca(p provisioner.Interface) (*linkedca.Provisioner, erro Claims: claimsToLinkedca(p.Claims), X509Template: x509Template, SshTemplate: sshTemplate, + Webhooks: webhooks, }, nil case *provisioner.OIDC: - x509Template, sshTemplate, err := provisionerOptionsToLinkedca(p.Options) + x509Template, sshTemplate, webhooks, err := provisionerOptionsToLinkedca(p.Options) if err != nil { return nil, err } @@ -929,9 +990,10 @@ func ProvisionerToLinkedca(p provisioner.Interface) (*linkedca.Provisioner, erro Claims: claimsToLinkedca(p.Claims), X509Template: x509Template, SshTemplate: sshTemplate, + Webhooks: webhooks, }, nil case *provisioner.GCP: - x509Template, sshTemplate, err := provisionerOptionsToLinkedca(p.Options) + x509Template, sshTemplate, webhooks, err := provisionerOptionsToLinkedca(p.Options) if err != nil { return nil, err } @@ -953,9 +1015,10 @@ func ProvisionerToLinkedca(p provisioner.Interface) (*linkedca.Provisioner, erro Claims: claimsToLinkedca(p.Claims), X509Template: x509Template, SshTemplate: sshTemplate, + Webhooks: webhooks, }, nil case *provisioner.AWS: - x509Template, sshTemplate, err := provisionerOptionsToLinkedca(p.Options) + x509Template, sshTemplate, webhooks, err := provisionerOptionsToLinkedca(p.Options) if err != nil { return nil, err } @@ -976,9 +1039,10 @@ func ProvisionerToLinkedca(p provisioner.Interface) (*linkedca.Provisioner, erro Claims: claimsToLinkedca(p.Claims), X509Template: x509Template, SshTemplate: sshTemplate, + Webhooks: webhooks, }, nil case *provisioner.Azure: - x509Template, sshTemplate, err := provisionerOptionsToLinkedca(p.Options) + x509Template, sshTemplate, webhooks, err := provisionerOptionsToLinkedca(p.Options) if err != nil { return nil, err } @@ -1002,9 +1066,10 @@ func ProvisionerToLinkedca(p provisioner.Interface) (*linkedca.Provisioner, erro Claims: claimsToLinkedca(p.Claims), X509Template: x509Template, SshTemplate: sshTemplate, + Webhooks: webhooks, }, nil case *provisioner.ACME: - x509Template, sshTemplate, err := provisionerOptionsToLinkedca(p.Options) + x509Template, sshTemplate, webhooks, err := provisionerOptionsToLinkedca(p.Options) if err != nil { return nil, err } @@ -1025,9 +1090,10 @@ func ProvisionerToLinkedca(p provisioner.Interface) (*linkedca.Provisioner, erro Claims: claimsToLinkedca(p.Claims), X509Template: x509Template, SshTemplate: sshTemplate, + Webhooks: webhooks, }, nil case *provisioner.X5C: - x509Template, sshTemplate, err := provisionerOptionsToLinkedca(p.Options) + x509Template, sshTemplate, webhooks, err := provisionerOptionsToLinkedca(p.Options) if err != nil { return nil, err } @@ -1045,9 +1111,10 @@ func ProvisionerToLinkedca(p provisioner.Interface) (*linkedca.Provisioner, erro Claims: claimsToLinkedca(p.Claims), X509Template: x509Template, SshTemplate: sshTemplate, + Webhooks: webhooks, }, nil case *provisioner.K8sSA: - x509Template, sshTemplate, err := provisionerOptionsToLinkedca(p.Options) + x509Template, sshTemplate, webhooks, err := provisionerOptionsToLinkedca(p.Options) if err != nil { return nil, err } @@ -1065,6 +1132,7 @@ func ProvisionerToLinkedca(p provisioner.Interface) (*linkedca.Provisioner, erro Claims: claimsToLinkedca(p.Claims), X509Template: x509Template, SshTemplate: sshTemplate, + Webhooks: webhooks, }, nil case *provisioner.SSHPOP: return &linkedca.Provisioner{ @@ -1079,7 +1147,7 @@ func ProvisionerToLinkedca(p provisioner.Interface) (*linkedca.Provisioner, erro Claims: claimsToLinkedca(p.Claims), }, nil case *provisioner.SCEP: - x509Template, sshTemplate, err := provisionerOptionsToLinkedca(p.Options) + x509Template, sshTemplate, webhooks, err := provisionerOptionsToLinkedca(p.Options) if err != nil { return nil, err } @@ -1102,9 +1170,10 @@ func ProvisionerToLinkedca(p provisioner.Interface) (*linkedca.Provisioner, erro Claims: claimsToLinkedca(p.Claims), X509Template: x509Template, SshTemplate: sshTemplate, + Webhooks: webhooks, }, nil case *provisioner.Nebula: - x509Template, sshTemplate, err := provisionerOptionsToLinkedca(p.Options) + x509Template, sshTemplate, webhooks, err := provisionerOptionsToLinkedca(p.Options) if err != nil { return nil, err } @@ -1122,6 +1191,7 @@ func ProvisionerToLinkedca(p provisioner.Interface) (*linkedca.Provisioner, erro Claims: claimsToLinkedca(p.Claims), X509Template: x509Template, SshTemplate: sshTemplate, + Webhooks: webhooks, }, nil default: return nil, fmt.Errorf("provisioner %s not implemented", p.GetType()) diff --git a/authority/provisioners_test.go b/authority/provisioners_test.go index 56cd16b1..ad214030 100644 --- a/authority/provisioners_test.go +++ b/authority/provisioners_test.go @@ -16,6 +16,7 @@ import ( "github.com/smallstep/certificates/db" "go.step.sm/crypto/jose" "go.step.sm/crypto/keyutil" + "go.step.sm/linkedca" ) func TestGetEncryptedKey(t *testing.T) { @@ -251,3 +252,82 @@ func TestAuthority_LoadProvisionerByCertificate(t *testing.T) { }) } } + +func TestProvisionerWebhookToLinkedca(t *testing.T) { + type test struct { + lwh *linkedca.Webhook + pwh *provisioner.Webhook + } + tests := map[string]test{ + "empty": test{ + lwh: &linkedca.Webhook{}, + pwh: &provisioner.Webhook{Kind: "NO_KIND", CertType: "ALL"}, + }, + "enriching ssh basic auth": test{ + lwh: &linkedca.Webhook{ + Id: "abc123", + Name: "people", + Url: "https://localhost", + Kind: linkedca.Webhook_ENRICHING, + Secret: "secret", + Auth: &linkedca.Webhook_BasicAuth{ + BasicAuth: &linkedca.BasicAuth{ + Username: "user", + Password: "pass", + }, + }, + DisableTlsClientAuth: true, + CertType: linkedca.Webhook_SSH, + }, + pwh: &provisioner.Webhook{ + ID: "abc123", + Name: "people", + URL: "https://localhost", + Kind: "ENRICHING", + Secret: "secret", + BasicAuth: struct { + Username string + Password string + }{ + Username: "user", + Password: "pass", + }, + DisableTLSClientAuth: true, + CertType: "SSH", + }, + }, + "authorizing x509 bearer auth": test{ + lwh: &linkedca.Webhook{ + Id: "abc123", + Name: "people", + Url: "https://localhost", + Kind: linkedca.Webhook_AUTHORIZING, + Secret: "secret", + Auth: &linkedca.Webhook_BearerToken{ + BearerToken: &linkedca.BearerToken{ + BearerToken: "tkn", + }, + }, + CertType: linkedca.Webhook_X509, + }, + pwh: &provisioner.Webhook{ + ID: "abc123", + Name: "people", + URL: "https://localhost", + Kind: "AUTHORIZING", + Secret: "secret", + BearerToken: "tkn", + CertType: "X509", + }, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + gotLWH := provisionerWebhookToLinkedca(test.pwh) + assert.Equals(t, test.lwh, gotLWH) + + gotPWH := webhookToCertificates(test.lwh) + assert.Equals(t, test.pwh, gotPWH) + }) + } +} diff --git a/authority/ssh.go b/authority/ssh.go index 1b243b39..7d990904 100644 --- a/authority/ssh.go +++ b/authority/ssh.go @@ -20,6 +20,7 @@ import ( "github.com/smallstep/certificates/db" "github.com/smallstep/certificates/errs" "github.com/smallstep/certificates/templates" + "github.com/smallstep/certificates/webhook" ) const ( @@ -161,6 +162,7 @@ func (a *Authority) SignSSH(ctx context.Context, key ssh.PublicKey, opts provisi opts.Backdate = a.config.AuthorityConfig.Backdate.Duration var prov provisioner.Interface + var webhookCtl webhookController for _, op := range signOpts { switch o := op.(type) { // Capture current provisioner @@ -185,6 +187,10 @@ func (a *Authority) SignSSH(ctx context.Context, key ssh.PublicKey, opts provisi return nil, errs.BadRequestErr(err, "error validating ssh certificate options") } + // call webhooks + case webhookController: + webhookCtl = o + default: return nil, errs.InternalServer("authority.SignSSH: invalid extra option type %T", o) } @@ -198,6 +204,14 @@ func (a *Authority) SignSSH(ctx context.Context, key ssh.PublicKey, opts provisi Key: key, } + // Call enriching webhooks + if err := callEnrichingWebhooksSSH(webhookCtl, cr); err != nil { + return nil, errs.ApplyOptions( + errs.ForbiddenErr(err, err.Error()), + errs.WithKeyVal("signOptions", signOpts), + ) + } + // Create certificate from template. certificate, err := sshutil.NewCertificate(cr, certOptions...) if err != nil { @@ -262,6 +276,13 @@ func (a *Authority) SignSSH(ctx context.Context, key ssh.PublicKey, opts provisi ) } + // Send certificate to webhooks for authorization + if err := callAuthorizingWebhooksSSH(webhookCtl, certificate, certTpl); err != nil { + return nil, errs.ApplyOptions( + errs.ForbiddenErr(err, "authority.SignSSH: error signing certificate"), + ) + } + // Sign certificate. cert, err := sshutil.CreateCertificate(certTpl, signer) if err != nil { @@ -631,3 +652,37 @@ func (a *Authority) getAddUserCommand(principal string) string { } return strings.ReplaceAll(cmd, "", principal) } + +func callEnrichingWebhooksSSH(webhookCtl webhookController, cr sshutil.CertificateRequest) error { + if webhookCtl == nil { + return nil + } + whEnrichReq, err := webhook.NewRequestBody( + webhook.WithSSHCertificateRequest(cr), + ) + if err != nil { + return err + } + if err := webhookCtl.Enrich(whEnrichReq); err != nil { + return err + } + + return nil +} + +func callAuthorizingWebhooksSSH(webhookCtl webhookController, cert *sshutil.Certificate, certTpl *ssh.Certificate) error { + if webhookCtl == nil { + return nil + } + whAuthBody, err := webhook.NewRequestBody( + webhook.WithSSHCertificate(cert, certTpl), + ) + if err != nil { + return err + } + if err := webhookCtl.Authorize(whAuthBody); err != nil { + return err + } + + return nil +} diff --git a/authority/ssh_test.go b/authority/ssh_test.go index 73916c03..c39b9901 100644 --- a/authority/ssh_test.go +++ b/authority/ssh_test.go @@ -178,6 +178,17 @@ func TestAuthority_SignSSH(t *testing.T) { }`}, }, sshutil.CreateTemplateData(sshutil.UserCert, "key-id", []string{"user"})) assert.FatalError(t, err) + enrichTemplateData := sshutil.CreateTemplateData(sshutil.UserCert, "key-id", []string{"user"}) + enrichTemplate, err := provisioner.TemplateSSHOptions(&provisioner.Options{ + SSH: &provisioner.SSHOptions{Template: `{ + "type": "{{ .Type }}", + "keyId": "{{ .KeyID }}", + "principals": {{ toJson .Webhooks.people.role }}, + "extensions": {{ set .Extensions "login@github.com" .Insecure.User.username | toJson }}, + "criticalOptions": {{ toJson .CriticalOptions }} + }`}, + }, enrichTemplateData) + assert.FatalError(t, err) userFailTemplate, err := provisioner.TemplateSSHOptions(&provisioner.Options{ SSH: &provisioner.SSHOptions{Template: `{{ fail "an error"}}`}, }, sshutil.CreateTemplateData(sshutil.UserCert, "key-id", []string{"user"})) @@ -255,6 +266,7 @@ func TestAuthority_SignSSH(t *testing.T) { {"ok-opts-validator", fields{signer, signer, nil}, args{pub, provisioner.SignSSHOptions{}, []provisioner.SignOption{userTemplate, userOptions, sshTestOptionsValidator("")}}, want{CertType: ssh.UserCert}, false}, {"ok-opts-modifier", fields{signer, signer, nil}, args{pub, provisioner.SignSSHOptions{}, []provisioner.SignOption{userTemplate, userOptions, sshTestOptionsModifier("")}}, want{CertType: ssh.UserCert}, false}, {"ok-custom-template", fields{signer, signer, nil}, args{pub, provisioner.SignSSHOptions{}, []provisioner.SignOption{userCustomTemplate, userOptions}}, want{CertType: ssh.UserCert, Principals: []string{"user", "admin"}}, false}, + {"ok-enrich-template", fields{signer, signer, nil}, args{pub, provisioner.SignSSHOptions{}, []provisioner.SignOption{enrichTemplate, userOptions, &mockWebhookController{templateData: enrichTemplateData, respData: map[string]any{"people": map[string]any{"role": []string{"user", "eng"}}}}}}, want{CertType: ssh.UserCert, Principals: []string{"user", "eng"}}, false}, {"ok-user-policy", fields{signer, signer, userPolicy}, args{pub, provisioner.SignSSHOptions{CertType: "user", Principals: []string{"user"}}, []provisioner.SignOption{userTemplateWithUser}}, want{CertType: ssh.UserCert, Principals: []string{"user"}}, false}, {"ok-host-policy", fields{signer, signer, hostPolicy}, args{pub, provisioner.SignSSHOptions{CertType: "host", Principals: []string{"foo.test.com", "bar.test.com"}}, []provisioner.SignOption{hostTemplateWithHosts}}, want{CertType: ssh.HostCert, Principals: []string{"foo.test.com", "bar.test.com"}}, false}, {"fail-opts-type", fields{signer, signer, nil}, args{pub, provisioner.SignSSHOptions{CertType: "foo"}, []provisioner.SignOption{userTemplate}}, want{}, true}, @@ -275,6 +287,8 @@ func TestAuthority_SignSSH(t *testing.T) { {"fail-host-policy", fields{signer, signer, hostPolicy}, args{pub, provisioner.SignSSHOptions{CertType: "host", Principals: []string{"example.com"}}, []provisioner.SignOption{hostTemplateWithExampleDotCom}}, want{}, true}, {"fail-host-policy-with-user-cert", fields{signer, signer, hostPolicy}, args{pub, provisioner.SignSSHOptions{CertType: "user", Principals: []string{"user"}}, []provisioner.SignOption{userTemplateWithUser}}, want{}, true}, {"fail-host-policy-with-bad-host", fields{signer, signer, hostPolicy}, args{pub, provisioner.SignSSHOptions{CertType: "host", Principals: []string{"example.com"}}, []provisioner.SignOption{badHostTemplate}}, want{}, true}, + {"fail-enriching-webhooks", fields{signer, signer, nil}, args{pub, provisioner.SignSSHOptions{}, []provisioner.SignOption{userTemplate, userOptions, &mockWebhookController{enrichErr: provisioner.ErrWebhookDenied}}}, want{}, true}, + {"fail-authorizing-webhooks", fields{signer, signer, nil}, args{pub, provisioner.SignSSHOptions{}, []provisioner.SignOption{userTemplate, userOptions, &mockWebhookController{authorizeErr: provisioner.ErrWebhookDenied}}}, want{}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/authority/tls.go b/authority/tls.go index efabc8f2..6ec98088 100644 --- a/authority/tls.go +++ b/authority/tls.go @@ -28,6 +28,7 @@ import ( casapi "github.com/smallstep/certificates/cas/apiv1" "github.com/smallstep/certificates/db" "github.com/smallstep/certificates/errs" + "github.com/smallstep/certificates/webhook" ) // GetTLSOptions returns the tls options configured. @@ -93,7 +94,8 @@ func (a *Authority) Sign(csr *x509.CertificateRequest, signOpts provisioner.Sign var prov provisioner.Interface var pInfo *casapi.ProvisionerInfo - var attData provisioner.AttestationData + var attData *provisioner.AttestationData + var webhookCtl webhookController for _, op := range extraOpts { switch k := op.(type) { // Capture current provisioner @@ -131,14 +133,25 @@ func (a *Authority) Sign(csr *x509.CertificateRequest, signOpts provisioner.Sign // Extra information from ACME attestations. case provisioner.AttestationData: - attData = k - // TODO(mariano,areed): remove me once attData is used. - _ = attData + attData = &k + + // Capture the provisioner's webhook controller + case webhookController: + webhookCtl = k + default: return nil, errs.InternalServer("authority.Sign; invalid extra option type %T", append([]interface{}{k}, opts...)...) } } + if err := callEnrichingWebhooksX509(webhookCtl, attData, csr); err != nil { + return nil, errs.ApplyOptions( + errs.ForbiddenErr(err, err.Error()), + errs.WithKeyVal("csr", csr), + errs.WithKeyVal("signOptions", signOpts), + ) + } + cert, err := x509util.NewCertificate(csr, certOptions...) if err != nil { var te *x509util.TemplateError @@ -223,6 +236,14 @@ func (a *Authority) Sign(csr *x509.CertificateRequest, signOpts provisioner.Sign ) } + // Send certificate to webhooks for authorization + if err := callAuthorizingWebhooksX509(webhookCtl, cert, leaf, attData); err != nil { + return nil, errs.ApplyOptions( + errs.ForbiddenErr(err, "error creating certificate"), + opts..., + ) + } + // Sign certificate lifetime := leaf.NotAfter.Sub(leaf.NotBefore.Add(signOpts.Backdate)) resp, err := a.x509CAService.CreateCertificate(&casapi.CreateCertificateRequest{ @@ -699,3 +720,51 @@ func templatingError(err error) error { } return errors.Wrap(cause, "error applying certificate template") } + +func callEnrichingWebhooksX509(webhookCtl webhookController, attData *provisioner.AttestationData, csr *x509.CertificateRequest) error { + if webhookCtl == nil { + return nil + } + var attested *webhook.AttestationData + if attData != nil { + attested = &webhook.AttestationData{ + PermanentIdentifier: attData.PermanentIdentifier, + } + } + whEnrichReq, err := webhook.NewRequestBody( + webhook.WithX509CertificateRequest(csr), + webhook.WithAttestationData(attested), + ) + if err != nil { + return err + } + if err := webhookCtl.Enrich(whEnrichReq); err != nil { + return err + } + + return nil +} + +func callAuthorizingWebhooksX509(webhookCtl webhookController, cert *x509util.Certificate, leaf *x509.Certificate, attData *provisioner.AttestationData) error { + if webhookCtl == nil { + return nil + } + var attested *webhook.AttestationData + if attData != nil { + attested = &webhook.AttestationData{ + PermanentIdentifier: attData.PermanentIdentifier, + } + } + whAuthBody, err := webhook.NewRequestBody( + webhook.WithX509Certificate(cert, leaf), + webhook.WithAttestationData(attested), + ) + if err != nil { + return err + } + if err := webhookCtl.Authorize(whAuthBody); err != nil { + return err + } + + return nil +} diff --git a/authority/tls_test.go b/authority/tls_test.go index bc9e3e3a..7b5a0b6c 100644 --- a/authority/tls_test.go +++ b/authority/tls_test.go @@ -547,6 +547,36 @@ ZYtQ9Ot36qc= code: http.StatusForbidden, } }, + "fail enriching webhooks": func(t *testing.T) *signTest { + csr := getCSR(t, priv) + csr.Raw = []byte("foo") + return &signTest{ + auth: a, + csr: csr, + extensionsCount: 7, + extraOpts: append(extraOpts, &mockWebhookController{ + enrichErr: provisioner.ErrWebhookDenied, + }), + signOpts: signOpts, + err: provisioner.ErrWebhookDenied, + code: http.StatusForbidden, + } + }, + "fail authorizing webhooks": func(t *testing.T) *signTest { + csr := getCSR(t, priv) + csr.Raw = []byte("foo") + return &signTest{ + auth: a, + csr: csr, + extensionsCount: 7, + extraOpts: append(extraOpts, &mockWebhookController{ + authorizeErr: provisioner.ErrWebhookDenied, + }), + signOpts: signOpts, + err: provisioner.ErrWebhookDenied, + code: http.StatusForbidden, + } + }, "ok": func(t *testing.T) *signTest { csr := getCSR(t, priv) _a := testAuthority(t) @@ -634,6 +664,48 @@ ZYtQ9Ot36qc= extensionsCount: 6, } }, + "ok with enriching webhook": func(t *testing.T) *signTest { + csr := getCSR(t, priv) + testAuthority := testAuthority(t) + testAuthority.config.AuthorityConfig.Template = a.config.AuthorityConfig.Template + p, ok := testAuthority.provisioners.Load("step-cli:4UELJx8e0aS9m0CH3fZ0EB7D5aUPICb759zALHFejvc") + if !ok { + t.Fatal("provisioner not found") + } + p.(*provisioner.JWK).Options = &provisioner.Options{ + X509: &provisioner.X509Options{Template: `{ + "subject": {"commonName": {{ toJson .Webhooks.people.role }} }, + "dnsNames": {{ toJson .Insecure.CR.DNSNames }}, + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["serverAuth","clientAuth"] + }`}, + } + testExtraOpts, err := testAuthority.Authorize(ctx, token) + assert.FatalError(t, err) + testAuthority.db = &db.MockAuthDB{ + MStoreCertificate: func(crt *x509.Certificate) error { + assert.Equals(t, crt.Subject.CommonName, "smallstep test") + return nil + }, + } + for i, o := range testExtraOpts { + if wc, ok := o.(*provisioner.WebhookController); ok { + testExtraOpts[i] = &mockWebhookController{ + templateData: wc.TemplateData, + respData: map[string]any{"people": map[string]any{"role": "smallstep test"}}, + } + } + } + return &signTest{ + auth: testAuthority, + csr: csr, + extraOpts: testExtraOpts, + signOpts: signOpts, + notBefore: signOpts.NotBefore.Time().Truncate(time.Second), + notAfter: signOpts.NotAfter.Time().Truncate(time.Second), + extensionsCount: 6, + } + }, "ok/csr with no template critical SAN extension": func(t *testing.T) *signTest { csr := getCSR(t, priv, func(csr *x509.CertificateRequest) { csr.Subject = pkix.Name{} diff --git a/authority/webhook.go b/authority/webhook.go new file mode 100644 index 00000000..d887e077 --- /dev/null +++ b/authority/webhook.go @@ -0,0 +1,8 @@ +package authority + +import "github.com/smallstep/certificates/webhook" + +type webhookController interface { + Enrich(*webhook.RequestBody) error + Authorize(*webhook.RequestBody) error +} diff --git a/authority/webhook_test.go b/authority/webhook_test.go new file mode 100644 index 00000000..b80c8f66 --- /dev/null +++ b/authority/webhook_test.go @@ -0,0 +1,27 @@ +package authority + +import ( + "github.com/smallstep/certificates/authority/provisioner" + "github.com/smallstep/certificates/webhook" +) + +type mockWebhookController struct { + enrichErr error + authorizeErr error + templateData provisioner.WebhookSetter + respData map[string]any +} + +var _ webhookController = &mockWebhookController{} + +func (wc *mockWebhookController) Enrich(req *webhook.RequestBody) error { + for key, data := range wc.respData { + wc.templateData.SetWebhook(key, data) + } + + return wc.enrichErr +} + +func (wc *mockWebhookController) Authorize(req *webhook.RequestBody) error { + return wc.authorizeErr +} diff --git a/ca/adminClient.go b/ca/adminClient.go index 84a0d413..cde197af 100644 --- a/ca/adminClient.go +++ b/ca/adminClient.go @@ -1101,6 +1101,103 @@ retry: return nil } +func (c *AdminClient) CreateProvisionerWebhook(provisionerName string, wh *linkedca.Webhook) (*linkedca.Webhook, error) { + var retried bool + body, err := protojson.Marshal(wh) + if err != nil { + return nil, fmt.Errorf("error marshaling request: %w", err) + } + u := c.endpoint.ResolveReference(&url.URL{Path: path.Join(adminURLPrefix, "provisioners", provisionerName, "webhooks")}) + tok, err := c.generateAdminToken(u) + if err != nil { + return nil, fmt.Errorf("error generating admin token: %w", err) + } +retry: + req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("creating POST %s request failed: %w", u, err) + } + req.Header.Add("Authorization", tok) + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("client POST %s failed: %w", u, err) + } + if resp.StatusCode >= 400 { + if !retried && c.retryOnError(resp) { + retried = true + goto retry + } + return nil, readAdminError(resp.Body) + } + var webhook = new(linkedca.Webhook) + if err := readProtoJSON(resp.Body, webhook); err != nil { + return nil, fmt.Errorf("error reading %s: %w", u, err) + } + return webhook, nil +} + +func (c *AdminClient) UpdateProvisionerWebhook(provisionerName string, wh *linkedca.Webhook) (*linkedca.Webhook, error) { + var retried bool + body, err := protojson.Marshal(wh) + if err != nil { + return nil, fmt.Errorf("error marshaling request: %w", err) + } + u := c.endpoint.ResolveReference(&url.URL{Path: path.Join(adminURLPrefix, "provisioners", provisionerName, "webhooks", wh.Name)}) + tok, err := c.generateAdminToken(u) + if err != nil { + return nil, fmt.Errorf("error generating admin token: %w", err) + } +retry: + req, err := http.NewRequest(http.MethodPut, u.String(), bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("creating PUT %s request failed: %w", u, err) + } + req.Header.Add("Authorization", tok) + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("client PUT %s failed: %w", u, err) + } + if resp.StatusCode >= 400 { + if !retried && c.retryOnError(resp) { + retried = true + goto retry + } + return nil, readAdminError(resp.Body) + } + var webhook = new(linkedca.Webhook) + if err := readProtoJSON(resp.Body, webhook); err != nil { + return nil, fmt.Errorf("error reading %s: %w", u, err) + } + return webhook, nil +} + +func (c *AdminClient) DeleteProvisionerWebhook(provisionerName, webhookName string) error { + var retried bool + u := c.endpoint.ResolveReference(&url.URL{Path: path.Join(adminURLPrefix, "provisioners", provisionerName, "webhooks", webhookName)}) + tok, err := c.generateAdminToken(u) + if err != nil { + return fmt.Errorf("error generating admin token: %w", err) + } +retry: + req, err := http.NewRequest(http.MethodDelete, u.String(), http.NoBody) + if err != nil { + return fmt.Errorf("creating DELETE %s request failed: %w", u, err) + } + req.Header.Add("Authorization", tok) + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("client DELETE %s failed: %w", u, err) + } + if resp.StatusCode >= 400 { + if !retried && c.retryOnError(resp) { + retried = true + goto retry + } + return readAdminError(resp.Body) + } + return nil +} + func readAdminError(r io.ReadCloser) error { // TODO: not all errors can be read (i.e. 404); seems to be a bigger issue defer r.Close() diff --git a/ca/ca.go b/ca/ca.go index 01b321d7..ab2aa8ac 100644 --- a/ca/ca.go +++ b/ca/ca.go @@ -156,17 +156,22 @@ func (ca *CA) Init(cfg *config.Config) (*CA, error) { opts = append(opts, authority.WithDatabase(ca.opts.database)) } + webhookTransport := http.DefaultTransport.(*http.Transport).Clone() + opts = append(opts, authority.WithWebhookClient(&http.Client{Transport: webhookTransport})) + auth, err := authority.New(cfg, opts...) if err != nil { return nil, err } ca.auth = auth - tlsConfig, err := ca.getTLSConfig(auth) + tlsConfig, clientTLSConfig, err := ca.getTLSConfig(auth) if err != nil { return nil, err } + webhookTransport.TLSClientConfig = clientTLSConfig + // Using chi as the main router mux := chi.NewRouter() handler := http.Handler(mux) @@ -220,8 +225,14 @@ func (ca *CA) Init(cfg *config.Config) (*CA, error) { if adminDB != nil { acmeAdminResponder := adminAPI.NewACMEAdminResponder() policyAdminResponder := adminAPI.NewPolicyAdminResponder() + webhookAdminResponder := adminAPI.NewWebhookAdminResponder() mux.Route("/admin", func(r chi.Router) { - adminAPI.Route(r, acmeAdminResponder, policyAdminResponder) + adminAPI.Route( + r, + adminAPI.WithACMEResponder(acmeAdminResponder), + adminAPI.WithPolicyResponder(policyAdminResponder), + adminAPI.WithWebhookResponder(webhookAdminResponder), + ) }) } } @@ -456,13 +467,13 @@ func (ca *CA) Reload() error { return nil } -// getTLSConfig returns a TLSConfig for the CA server with a self-renewing -// server certificate. -func (ca *CA) getTLSConfig(auth *authority.Authority) (*tls.Config, error) { +// get TLSConfig returns separate TLSConfigs for server and client with the +// same self-renewing certificate. +func (ca *CA) getTLSConfig(auth *authority.Authority) (*tls.Config, *tls.Config, error) { // Create initial TLS certificate tlsCrt, err := auth.GetTLSCertificate() if err != nil { - return nil, err + return nil, nil, err } // Start tls renewer with the new certificate. @@ -473,15 +484,15 @@ func (ca *CA) getTLSConfig(auth *authority.Authority) (*tls.Config, error) { ca.renewer, err = NewTLSRenewer(tlsCrt, auth.GetTLSCertificate) if err != nil { - return nil, err + return nil, nil, err } ca.renewer.Run() - var tlsConfig *tls.Config + var serverTLSConfig *tls.Config if ca.config.TLS != nil { - tlsConfig = ca.config.TLS.TLSConfig() + serverTLSConfig = ca.config.TLS.TLSConfig() } else { - tlsConfig = &tls.Config{ + serverTLSConfig = &tls.Config{ MinVersion: tls.VersionTLS12, } } @@ -493,13 +504,24 @@ func (ca *CA) getTLSConfig(auth *authority.Authority) (*tls.Config, error) { // first entry in the Certificates attribute; by setting the attribute to // empty we are implicitly forcing GetCertificate to be the only mechanism // by which the server can find it's own leaf Certificate. - tlsConfig.Certificates = []tls.Certificate{} - tlsConfig.GetCertificate = ca.renewer.GetCertificateForCA + serverTLSConfig.Certificates = []tls.Certificate{} + + clientTLSConfig := serverTLSConfig.Clone() + + serverTLSConfig.GetCertificate = ca.renewer.GetCertificateForCA + clientTLSConfig.GetClientCertificate = ca.renewer.GetClientCertificate // initialize a certificate pool with root CA certificates to trust when doing mTLS. certPool := x509.NewCertPool() + // initialize a certificate pool with root CA certificates to trust when connecting + // to webhook servers + rootCAsPool, err := x509.SystemCertPool() + if err != nil { + return nil, nil, err + } for _, crt := range auth.GetRootCertificates() { certPool.AddCert(crt) + rootCAsPool.AddCert(crt) } // adding the intermediate CA certificates to the pool will allow clients that @@ -509,16 +531,19 @@ func (ca *CA) getTLSConfig(auth *authority.Authority) (*tls.Config, error) { for _, certBytes := range intermediates { cert, err := x509.ParseCertificate(certBytes) if err != nil { - return nil, err + return nil, nil, err } certPool.AddCert(cert) + rootCAsPool.AddCert(cert) } // Add support for mutual tls to renew certificates - tlsConfig.ClientAuth = tls.VerifyClientCertIfGiven - tlsConfig.ClientCAs = certPool + serverTLSConfig.ClientAuth = tls.VerifyClientCertIfGiven + serverTLSConfig.ClientCAs = certPool + + clientTLSConfig.RootCAs = rootCAsPool - return tlsConfig, nil + return serverTLSConfig, clientTLSConfig, nil } // shouldServeSCEPEndpoints returns if the CA should be diff --git a/go.mod b/go.mod index 1ba86c92..0a663587 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( cloud.google.com/go v0.102.1 cloud.google.com/go/security v1.7.0 github.com/Azure/azure-sdk-for-go v65.0.0+incompatible // indirect - github.com/Azure/go-autorest/autorest v0.11.27 // indirect + github.com/Azure/go-autorest/autorest v0.11.28 // indirect github.com/Azure/go-autorest/autorest/azure/auth v0.5.11 // indirect github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect github.com/Masterminds/sprig/v3 v3.2.2 @@ -22,7 +22,7 @@ require ( github.com/golang/mock v1.6.0 github.com/google/go-cmp v0.5.8 github.com/google/uuid v1.3.0 - github.com/googleapis/gax-go/v2 v2.4.0 + github.com/googleapis/gax-go/v2 v2.5.1 github.com/hashicorp/vault/api v1.3.1 github.com/hashicorp/vault/api/auth/approle v0.1.1 github.com/hashicorp/vault/api/auth/kubernetes v0.1.0 @@ -42,13 +42,13 @@ require ( github.com/urfave/cli v1.22.4 go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 go.step.sm/cli-utils v0.7.4 - go.step.sm/crypto v0.19.0 + go.step.sm/crypto v0.19.1-0.20220929182301-ae99d3fe3185 go.step.sm/linkedca v0.19.0-rc.2 golang.org/x/crypto v0.0.0-20220919173607-35f4265a4bc0 golang.org/x/net v0.0.0-20220927171203-f486391704dc golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec // indirect golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba // indirect - google.golang.org/api v0.96.0 + google.golang.org/api v0.97.0 google.golang.org/genproto v0.0.0-20220929141241-1ce7b20da813 google.golang.org/grpc v1.49.0 google.golang.org/protobuf v1.28.1 @@ -147,6 +147,7 @@ require ( // replace github.com/smallstep/nosql => ../nosql // replace go.step.sm/crypto => ../crypto + // replace go.step.sm/cli-utils => ../cli-utils // replace go.step.sm/linkedca => ../linkedca diff --git a/go.sum b/go.sum index 75f369cd..00530337 100644 --- a/go.sum +++ b/go.sum @@ -73,8 +73,8 @@ github.com/Azure/azure-sdk-for-go v65.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9mo github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= github.com/Azure/go-autorest/autorest v0.11.24/go.mod h1:G6kyRlFnTuSbEYkQGawPfsCswgme4iYf6rfSKUDzbCc= -github.com/Azure/go-autorest/autorest v0.11.27 h1:F3R3q42aWytozkV8ihzcgMO4OA4cuqr3bNlsEuF6//A= -github.com/Azure/go-autorest/autorest v0.11.27/go.mod h1:7l8ybrIdUmGqZMTD0sRtAr8NvbHjfofbf8RSP2q7w7U= +github.com/Azure/go-autorest/autorest v0.11.28 h1:ndAExarwr5Y+GaHE6VCaY1kyS/HwwGGyuimVhWsHOEM= +github.com/Azure/go-autorest/autorest v0.11.28/go.mod h1:MrkzG3Y3AH668QyF9KRk5neJnGgmhQ6krbhR8Q5eMvA= github.com/Azure/go-autorest/autorest/adal v0.9.18 h1:kLnPsRjzZZUF3K5REu/Kc+qMQrvuza2bwSnNdhmzLfQ= github.com/Azure/go-autorest/autorest/adal v0.9.18/go.mod h1:XVVeme+LZwABT8K5Lc3hA4nAe8LDBVle26gTrguhhPQ= github.com/Azure/go-autorest/autorest/azure/auth v0.5.11 h1:P6bYXFoao05z5uhOQzbC3Qd8JqF3jUoocoTeIxkp2cA= @@ -362,8 +362,9 @@ github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pf github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM= github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM= -github.com/googleapis/gax-go/v2 v2.4.0 h1:dS9eYAjhrE2RjmzYw2XAPvcXfmcQLtFEQWn0CR82awk= github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c= +github.com/googleapis/gax-go/v2 v2.5.1 h1:kBRZU0PSuI7PspsSb/ChWoVResUcwNVIdpB049pKTiw= +github.com/googleapis/gax-go/v2 v2.5.1/go.mod h1:h6B0KMMFNtI2ddbGJn3T3ZbwkeT6yqEF02fYlzkUCyo= github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gordonklaus/ineffassign v0.0.0-20200309095847-7953dde2c7bf/go.mod h1:cuNKsD1zp2v6XfE/orVX2QE1LC+i254ceGcVeDT3pTU= @@ -783,8 +784,8 @@ go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqe go.step.sm/cli-utils v0.7.4 h1:oI7PStZqlvjPZ0u2EB4lN7yZ4R3ShTotdGL/L84Oorg= go.step.sm/cli-utils v0.7.4/go.mod h1:taSsY8haLmXoXM3ZkywIyRmVij/4Aj0fQbNTlJvv71I= go.step.sm/crypto v0.9.0/go.mod h1:+CYG05Mek1YDqi5WK0ERc6cOpKly2i/a5aZmU1sfGj0= -go.step.sm/crypto v0.19.0 h1:WxjUDeTDpuPZ1IR3v6c4jc6WdlQlS5IYYQBhfnG5uW0= -go.step.sm/crypto v0.19.0/go.mod h1:qZ+pNU1nV+THwP7TPTNCRMRr9xrRURhETTAK7U5psfw= +go.step.sm/crypto v0.19.1-0.20220929182301-ae99d3fe3185 h1:W+UhojTrFZngWTudpP3n9vPs4UNLudVSkKrWZuZg/RU= +go.step.sm/crypto v0.19.1-0.20220929182301-ae99d3fe3185/go.mod h1:972LarNeN9dgx4+zkF3fHCnTWLXzuQSIOdMaGeIslUY= go.step.sm/linkedca v0.19.0-rc.2 h1:IcPqZ5y7MZNq1+VbYQcKoQEvX80NKRncU1WFCDyY+So= go.step.sm/linkedca v0.19.0-rc.2/go.mod h1:MCZmPIdzElEofZbiw4eyUHayXgFTwa94cNAV34aJ5ew= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= @@ -818,6 +819,7 @@ golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220919173607-35f4265a4bc0 h1:a5Yg6ylndHHYJqIPrdq0AhvR6KTvDTAvgBtaidhEevY= golang.org/x/crypto v0.0.0-20220919173607-35f4265a4bc0/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1160,8 +1162,8 @@ google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69 google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw= google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg= google.golang.org/api v0.84.0/go.mod h1:NTsGnUFJMYROtiquksZHBWtHfeMC7iYthki7Eq3pa8o= -google.golang.org/api v0.96.0 h1:F60cuQPJq7K7FzsxMYHAUJSiXh2oKctHxBMbDygxhfM= -google.golang.org/api v0.96.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= +google.golang.org/api v0.97.0 h1:x/vEL1XDF/2V4xzdNgFPaKHluRESo2aTsL7QzHnBtGQ= +google.golang.org/api v0.97.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= diff --git a/scep/authority.go b/scep/authority.go index bdba1d5f..585b937e 100644 --- a/scep/authority.go +++ b/scep/authority.go @@ -281,6 +281,14 @@ func (a *Authority) SignCSR(ctx context.Context, csr *x509.CertificateRequest, m if err != nil { return nil, fmt.Errorf("error retrieving authorization options from SCEP provisioner: %w", err) } + // Unlike most of the provisioners, scep's AuthorizeSign method doesn't + // define the templates, and the template data used in WebHooks is not + // available. + for _, signOp := range signOps { + if wc, ok := signOp.(*provisioner.WebhookController); ok { + wc.TemplateData = data + } + } opts := provisioner.SignOptions{} templateOptions, err := provisioner.TemplateOptions(p.GetOptions(), data) diff --git a/webhook/options.go b/webhook/options.go new file mode 100644 index 00000000..88c44986 --- /dev/null +++ b/webhook/options.go @@ -0,0 +1,97 @@ +package webhook + +import ( + "crypto/x509" + + "go.step.sm/crypto/sshutil" + "go.step.sm/crypto/x509util" + "golang.org/x/crypto/ssh" +) + +type RequestBodyOption func(*RequestBody) error + +func NewRequestBody(options ...RequestBodyOption) (*RequestBody, error) { + rb := &RequestBody{} + + for _, fn := range options { + if err := fn(rb); err != nil { + return nil, err + } + } + + return rb, nil +} + +func WithX509CertificateRequest(cr *x509.CertificateRequest) RequestBodyOption { + return func(rb *RequestBody) error { + rb.X509CertificateRequest = &X509CertificateRequest{ + CertificateRequest: x509util.NewCertificateRequestFromX509(cr), + PublicKeyAlgorithm: cr.PublicKeyAlgorithm.String(), + Raw: cr.Raw, + } + if cr.PublicKey != nil { + key, err := x509.MarshalPKIXPublicKey(cr.PublicKey) + if err != nil { + return err + } + rb.X509CertificateRequest.PublicKey = key + } + + return nil + } +} + +func WithX509Certificate(cert *x509util.Certificate, leaf *x509.Certificate) RequestBodyOption { + return func(rb *RequestBody) error { + rb.X509Certificate = &X509Certificate{ + Certificate: cert, + PublicKeyAlgorithm: leaf.PublicKeyAlgorithm.String(), + NotBefore: leaf.NotBefore, + NotAfter: leaf.NotAfter, + } + if leaf.PublicKey != nil { + key, err := x509.MarshalPKIXPublicKey(leaf.PublicKey) + if err != nil { + return err + } + rb.X509Certificate.PublicKey = key + } + + return nil + } +} + +func WithAttestationData(data *AttestationData) RequestBodyOption { + return func(rb *RequestBody) error { + rb.AttestationData = data + return nil + } +} + +func WithSSHCertificateRequest(cr sshutil.CertificateRequest) RequestBodyOption { + return func(rb *RequestBody) error { + rb.SSHCertificateRequest = &SSHCertificateRequest{ + Type: cr.Type, + KeyID: cr.KeyID, + Principals: cr.Principals, + } + if cr.Key != nil { + rb.SSHCertificateRequest.PublicKey = cr.Key.Marshal() + } + return nil + } +} + +func WithSSHCertificate(cert *sshutil.Certificate, certTpl *ssh.Certificate) RequestBodyOption { + return func(rb *RequestBody) error { + rb.SSHCertificate = &SSHCertificate{ + Certificate: cert, + ValidBefore: certTpl.ValidBefore, + ValidAfter: certTpl.ValidAfter, + } + if certTpl.Key != nil { + rb.SSHCertificate.PublicKey = certTpl.Key.Marshal() + } + return nil + } +} diff --git a/webhook/options_test.go b/webhook/options_test.go new file mode 100644 index 00000000..e813bb44 --- /dev/null +++ b/webhook/options_test.go @@ -0,0 +1,116 @@ +package webhook + +import ( + "crypto/x509" + "crypto/x509/pkix" + "testing" + "time" + + "github.com/smallstep/assert" + "go.step.sm/crypto/sshutil" + "go.step.sm/crypto/x509util" + "golang.org/x/crypto/ssh" +) + +func TestNewRequestBody(t *testing.T) { + t1 := time.Now() + t2 := t1.Add(time.Hour) + + type test struct { + options []RequestBodyOption + want *RequestBody + wantErr bool + } + tests := map[string]test{ + "Permanent Identifier": { + options: []RequestBodyOption{WithAttestationData(&AttestationData{PermanentIdentifier: "mydevice123"})}, + want: &RequestBody{ + AttestationData: &AttestationData{ + PermanentIdentifier: "mydevice123", + }, + }, + wantErr: false, + }, + "X509 Certificate Request": { + options: []RequestBodyOption{ + WithX509CertificateRequest(&x509.CertificateRequest{ + PublicKeyAlgorithm: x509.ECDSA, + Subject: pkix.Name{CommonName: "foo"}, + Raw: []byte("csr der"), + }), + }, + want: &RequestBody{ + X509CertificateRequest: &X509CertificateRequest{ + CertificateRequest: &x509util.CertificateRequest{ + PublicKeyAlgorithm: x509.ECDSA, + Subject: x509util.Subject{CommonName: "foo"}, + }, + PublicKeyAlgorithm: "ECDSA", + Raw: []byte("csr der"), + }, + }, + wantErr: false, + }, + "X509 Certificate": { + options: []RequestBodyOption{ + WithX509Certificate(&x509util.Certificate{}, &x509.Certificate{ + NotBefore: t1, + NotAfter: t2, + PublicKeyAlgorithm: x509.ECDSA, + }), + }, + want: &RequestBody{ + X509Certificate: &X509Certificate{ + Certificate: &x509util.Certificate{}, + PublicKeyAlgorithm: "ECDSA", + NotBefore: t1, + NotAfter: t2, + }, + }, + }, + "SSH Certificate Request": { + options: []RequestBodyOption{ + WithSSHCertificateRequest(sshutil.CertificateRequest{ + Type: "User", + KeyID: "key1", + Principals: []string{"areed", "other"}, + })}, + want: &RequestBody{ + SSHCertificateRequest: &SSHCertificateRequest{ + Type: "User", + KeyID: "key1", + Principals: []string{"areed", "other"}, + }, + }, + wantErr: false, + }, + "SSH Certificate": { + options: []RequestBodyOption{ + WithSSHCertificate( + &sshutil.Certificate{}, + &ssh.Certificate{ + ValidAfter: uint64(t1.Unix()), + ValidBefore: uint64(t2.Unix()), + }, + ), + }, + want: &RequestBody{ + SSHCertificate: &SSHCertificate{ + Certificate: &sshutil.Certificate{}, + ValidAfter: uint64(t1.Unix()), + ValidBefore: uint64(t2.Unix()), + }, + }, + wantErr: false, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + got, err := NewRequestBody(test.options...) + if (err != nil) != test.wantErr { + t.Fatalf("Got err %v, wanted %t", err, test.wantErr) + } + assert.Equals(t, test.want, got) + }) + } +} diff --git a/webhook/types.go b/webhook/types.go new file mode 100644 index 00000000..19624f5c --- /dev/null +++ b/webhook/types.go @@ -0,0 +1,71 @@ +package webhook + +import ( + "time" + + "go.step.sm/crypto/sshutil" + "go.step.sm/crypto/x509util" +) + +// ResponseBody is the body returned by webhook servers. +type ResponseBody struct { + Data any `json:"data"` + Allow bool `json:"allow"` +} + +// X509CertificateRequest is the certificate request sent to webhook servers for +// enriching webhooks when signing x509 certificates +type X509CertificateRequest struct { + *x509util.CertificateRequest + PublicKey []byte `json:"publicKey"` + PublicKeyAlgorithm string `json:"publicKeyAlgorithm"` + Raw []byte `json:"raw"` +} + +// X509Certificate is the certificate sent to webhook servers for authorizing +// webhooks when signing x509 certificates +type X509Certificate struct { + *x509util.Certificate + PublicKey []byte `json:"publicKey"` + PublicKeyAlgorithm string `json:"publicKeyAlgorithm"` + NotBefore time.Time `json:"notBefore"` + NotAfter time.Time `json:"notAfter"` +} + +// SSHCertificateRequest is the certificate request sent to webhook servers for +// enriching webhooks when signing SSH certificates +type SSHCertificateRequest struct { + PublicKey []byte `json:"publicKey"` + Type string `json:"type"` + KeyID string `json:"keyID"` + Principals []string `json:"principals"` +} + +// SSHCertificate is the certificate sent to webhook servers for authorizing +// webhooks when signing SSH certificates +type SSHCertificate struct { + *sshutil.Certificate + PublicKey []byte `json:"publicKey"` + SignatureKey []byte `json:"signatureKey"` + ValidBefore uint64 `json:"validBefore"` + ValidAfter uint64 `json:"validAfter"` +} + +// AttestationData is data validated by acme device-attest-01 challenge +type AttestationData struct { + PermanentIdentifier string `json:"permanentIdentifier"` +} + +// RequestBody is the body sent to webhook servers. +type RequestBody struct { + Timestamp time.Time `json:"timestamp"` + // Only set after successfully completing acme device-attest-01 challenge + AttestationData *AttestationData `json:"attestationData,omitempty"` + // Set for most provisioners, but not acme or scep + // Token any `json:"token,omitempty"` + // Exactly one of the remaining fields should be set + X509CertificateRequest *X509CertificateRequest `json:"x509CertificateRequest,omitempty"` + X509Certificate *X509Certificate `json:"x509Certificate,omitempty"` + SSHCertificateRequest *SSHCertificateRequest `json:"sshCertificateRequest,omitempty"` + SSHCertificate *SSHCertificate `json:"sshCertificate,omitempty"` +}