Provisioner webhooks (#1001)

pull/1069/head
Andrew Reed 2 years ago committed by GitHub
parent 6fe0fc852a
commit 7101fbb0ee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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

@ -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 {

@ -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))
}
}

@ -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)
}

@ -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)
})
}
}

@ -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
}

@ -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)
})
}
}

@ -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

@ -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
}
}
})

@ -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 {

@ -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

@ -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))
}

@ -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
}

@ -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))
}

@ -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
}

@ -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))
}

@ -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 {

@ -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
}

@ -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))
}

@ -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
}

@ -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))
}

@ -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
}

@ -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))
}

@ -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
}

@ -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
}

@ -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))
}

@ -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

@ -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

@ -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 {

@ -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
}

@ -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)
}

@ -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-----

@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIJmnxm3N/ahRA2PWeZhRGJUKPU1lI44WcE4P1bynIim6oAoGCCqGSM49
AwEHoUQDQgAEG6bXw6JxWnlD4k6+dsJfa93XM4K3qJcA0ZrIMhYJ69RLih+elkXI
B9YI3y9KcmxZTtz3YAbpEeLvrL3qt+NzgA==
-----END EC PRIVATE KEY-----

@ -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
}

@ -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)
})
}

@ -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
}

@ -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)
}
}
}

@ -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())

@ -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)
})
}
}

@ -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>", 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
}

@ -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) {

@ -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
}

@ -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{}

@ -0,0 +1,8 @@
package authority
import "github.com/smallstep/certificates/webhook"
type webhookController interface {
Enrich(*webhook.RequestBody) error
Authorize(*webhook.RequestBody) error
}

@ -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
}

@ -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()

@ -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

@ -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

@ -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=

@ -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)

@ -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
}
}

@ -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)
})
}
}

@ -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"`
}
Loading…
Cancel
Save