Provisioner webhooks (#1001)
parent
6fe0fc852a
commit
7101fbb0ee
@ -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,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)
|
||||||
|
})
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
@ -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…
Reference in New Issue