Skip to content
Open
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

---

## [Unreleased]

### Fixed

- Reject mismatched provisioner types and details in admin API create and
update requests, and return bad-request errors for invalid configurations.
- Updating a provisioner webhook now responds with `200 OK` instead of
`201 Created`.

## [0.30.2] - 2026-03-22

- Update golang.org/grpc to patch security advisory
Expand Down
2 changes: 0 additions & 2 deletions authority/admin/api/provisioner.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ func CreateProvisioner(w http.ResponseWriter, r *http.Request) {
return
}

// TODO: Validate inputs
if err := authority.ValidateClaims(prov.Claims); err != nil {
render.Error(w, r, err)
return
Expand Down Expand Up @@ -182,7 +181,6 @@ func UpdateProvisioner(w http.ResponseWriter, r *http.Request) {
return
}

// TODO: Validate inputs
if err := authority.ValidateClaims(nu.Claims); err != nil {
render.Error(w, r, err)
return
Expand Down
2 changes: 1 addition & 1 deletion authority/admin/api/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,5 +229,5 @@ func (war *webhookAdminResponder) UpdateProvisionerWebhook(w http.ResponseWriter
Auth: newWebhook.Auth,
DisableTlsClientAuth: newWebhook.DisableTlsClientAuth,
}
render.ProtoJSONStatus(w, whResponse, http.StatusCreated)
render.ProtoJSONStatus(w, whResponse, http.StatusOK)
}
2 changes: 1 addition & 1 deletion authority/admin/api/webhook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -632,7 +632,7 @@ func TestWebhookAdminResponder_UpdateProvisionerWebhook(t *testing.T) {
Url: "https://example.com",
Kind: linkedca.Webhook_ENRICHING,
},
statusCode: 201,
statusCode: 200,
}
},
}
Expand Down
99 changes: 91 additions & 8 deletions authority/provisioners.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,9 +213,14 @@ func (a *Authority) StoreProvisioner(ctx context.Context, prov *linkedca.Provisi
a.adminMutex.Lock()
defer a.adminMutex.Unlock()

if err := validateProvisionerTypeAndDetails(prov); err != nil {
return admin.WrapError(admin.ErrorBadRequestType, err,
"error validating linkedca provisioner")
}

certProv, err := ProvisionerToCertificates(prov)
if err != nil {
return admin.WrapErrorISE(err,
return admin.WrapError(admin.ErrorBadRequestType, err,
"error converting to certificates provisioner from linkedca provisioner")
}

Expand Down Expand Up @@ -271,9 +276,14 @@ func (a *Authority) UpdateProvisioner(ctx context.Context, nu *linkedca.Provisio
a.adminMutex.Lock()
defer a.adminMutex.Unlock()

if err := validateProvisionerTypeAndDetails(nu); err != nil {
return admin.WrapError(admin.ErrorBadRequestType, err,
"error validating linkedca provisioner")
}

certProv, err := ProvisionerToCertificates(nu)
if err != nil {
return admin.WrapErrorISE(err,
return admin.WrapError(admin.ErrorBadRequestType, err,
"error converting to certificates provisioner from linkedca provisioner")
}

Expand All @@ -287,7 +297,7 @@ func (a *Authority) UpdateProvisioner(ctx context.Context, nu *linkedca.Provisio
}

if err := certProv.Init(provisionerConfig); err != nil {
return admin.WrapErrorISE(err, "error initializing provisioner %s", nu.Name)
return admin.WrapError(admin.ErrorBadRequestType, err, "error validating configuration for provisioner %q", nu.Name)
}

if err := a.provisioners.Update(certProv); err != nil {
Expand Down Expand Up @@ -821,22 +831,95 @@ func provisionerPEMToCertificates(bs [][]byte) []byte {
return roots
}

func provisionerTypeFromDetails(details *linkedca.ProvisionerDetails) (linkedca.Provisioner_Type, error) {
if details == nil {
return linkedca.Provisioner_NOOP, errors.New("provisioner details are required")
}

switch d := details.GetData().(type) {
case *linkedca.ProvisionerDetails_JWK:
if d.JWK != nil {
return linkedca.Provisioner_JWK, nil
}
case *linkedca.ProvisionerDetails_OIDC:
if d.OIDC != nil {
return linkedca.Provisioner_OIDC, nil
}
case *linkedca.ProvisionerDetails_GCP:
if d.GCP != nil {
return linkedca.Provisioner_GCP, nil
}
case *linkedca.ProvisionerDetails_AWS:
if d.AWS != nil {
return linkedca.Provisioner_AWS, nil
}
case *linkedca.ProvisionerDetails_Azure:
if d.Azure != nil {
return linkedca.Provisioner_AZURE, nil
}
case *linkedca.ProvisionerDetails_ACME:
if d.ACME != nil {
return linkedca.Provisioner_ACME, nil
}
case *linkedca.ProvisionerDetails_X5C:
if d.X5C != nil {
return linkedca.Provisioner_X5C, nil
}
case *linkedca.ProvisionerDetails_K8SSA:
if d.K8SSA != nil {
return linkedca.Provisioner_K8SSA, nil
}
case *linkedca.ProvisionerDetails_SSHPOP:
if d.SSHPOP != nil {
return linkedca.Provisioner_SSHPOP, nil
}
case *linkedca.ProvisionerDetails_SCEP:
if d.SCEP != nil {
return linkedca.Provisioner_SCEP, nil
}
case *linkedca.ProvisionerDetails_Nebula:
if d.Nebula != nil {
return linkedca.Provisioner_NEBULA, nil
}
}

return linkedca.Provisioner_NOOP, errors.New("provisioner details are required")
}

func validateProvisionerTypeAndDetails(p *linkedca.Provisioner) error {
detailsType, err := provisionerTypeFromDetails(p.GetDetails())
if err != nil {
return err
}
if detailsType != p.GetType() {
return errors.Errorf(
"provisioner details (%s) do not match provisioner type (%s)",
detailsType, p.GetType(),
)
}
return nil
}

// ProvisionerToCertificates converts the linkedca provisioner type to the certificates provisioner
// interface.
// interface. It deliberately dispatches on details instead of requiring the
// type enum to match so that legacy records remain loadable. New writes enforce
// the type/details invariant with validateProvisionerTypeAndDetails.
func ProvisionerToCertificates(p *linkedca.Provisioner) (provisioner.Interface, error) {
claims, err := claimsToCertificates(p.Claims)
if err != nil {
return nil, err
}

details := p.Details.GetData()
if details == nil {
return nil, errors.New("provisioner does not have any details")
details := p.GetDetails()
if _, err := provisionerTypeFromDetails(details); err != nil {
return nil, err
}

data := details.GetData()

options := optionsToCertificates(p)

switch d := details.(type) {
switch d := data.(type) {
case *linkedca.ProvisionerDetails_JWK:
jwk := new(jose.JSONWebKey)
if err := json.Unmarshal(d.JWK.PublicKey, &jwk); err != nil {
Expand Down
133 changes: 133 additions & 0 deletions authority/provisioners_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,87 @@ func TestProvisionerWebhookToLinkedca(t *testing.T) {
}
}

func TestProvisionerTypeFromDetails(t *testing.T) {
tests := map[string]struct {
details *linkedca.ProvisionerDetails
want linkedca.Provisioner_Type
wantErr bool
}{
"jwk": {details: &linkedca.ProvisionerDetails{Data: &linkedca.ProvisionerDetails_JWK{JWK: &linkedca.JWKProvisioner{}}}, want: linkedca.Provisioner_JWK},
"oidc": {details: &linkedca.ProvisionerDetails{Data: &linkedca.ProvisionerDetails_OIDC{OIDC: &linkedca.OIDCProvisioner{}}}, want: linkedca.Provisioner_OIDC},
"gcp": {details: &linkedca.ProvisionerDetails{Data: &linkedca.ProvisionerDetails_GCP{GCP: &linkedca.GCPProvisioner{}}}, want: linkedca.Provisioner_GCP},
"aws": {details: &linkedca.ProvisionerDetails{Data: &linkedca.ProvisionerDetails_AWS{AWS: &linkedca.AWSProvisioner{}}}, want: linkedca.Provisioner_AWS},
"azure": {details: &linkedca.ProvisionerDetails{Data: &linkedca.ProvisionerDetails_Azure{Azure: &linkedca.AzureProvisioner{}}}, want: linkedca.Provisioner_AZURE},
"acme": {details: &linkedca.ProvisionerDetails{Data: &linkedca.ProvisionerDetails_ACME{ACME: &linkedca.ACMEProvisioner{}}}, want: linkedca.Provisioner_ACME},
"x5c": {details: &linkedca.ProvisionerDetails{Data: &linkedca.ProvisionerDetails_X5C{X5C: &linkedca.X5CProvisioner{}}}, want: linkedca.Provisioner_X5C},
"k8ssa": {details: &linkedca.ProvisionerDetails{Data: &linkedca.ProvisionerDetails_K8SSA{K8SSA: &linkedca.K8SSAProvisioner{}}}, want: linkedca.Provisioner_K8SSA},
"sshpop": {details: &linkedca.ProvisionerDetails{Data: &linkedca.ProvisionerDetails_SSHPOP{SSHPOP: &linkedca.SSHPOPProvisioner{}}}, want: linkedca.Provisioner_SSHPOP},
"scep": {details: &linkedca.ProvisionerDetails{Data: &linkedca.ProvisionerDetails_SCEP{SCEP: &linkedca.SCEPProvisioner{}}}, want: linkedca.Provisioner_SCEP},
"nebula": {details: &linkedca.ProvisionerDetails{Data: &linkedca.ProvisionerDetails_Nebula{Nebula: &linkedca.NebulaProvisioner{}}}, want: linkedca.Provisioner_NEBULA},
"nil details": {wantErr: true},
"missing oneof": {details: &linkedca.ProvisionerDetails{}, wantErr: true},
"nil oneof value": {details: &linkedca.ProvisionerDetails{Data: &linkedca.ProvisionerDetails_AWS{}}, wantErr: true},
}

for name, tc := range tests {
t.Run(name, func(t *testing.T) {
got, err := provisionerTypeFromDetails(tc.details)
if tc.wantErr {
if err == nil {
t.Fatal("provisionerTypeFromDetails() error = nil, want error")
}
return
}
if err != nil {
t.Fatalf("provisionerTypeFromDetails() error = %v", err)
}
assert.Equals(t, tc.want, got)
})
}
}

func TestProvisionerListToCertificates_legacyTypeDetailsMismatch(t *testing.T) {
p := &linkedca.Provisioner{
Name: "mismatch",
Type: linkedca.Provisioner_JWK,
Details: &linkedca.ProvisionerDetails{Data: &linkedca.ProvisionerDetails_ACME{
ACME: &linkedca.ACMEProvisioner{},
}},
}

got, err := provisionerListToCertificates([]*linkedca.Provisioner{p})
if err != nil {
t.Fatalf("provisionerListToCertificates() error = %v, want legacy mismatch to load", err)
}
require.Len(t, got, 1)
acme, ok := got[0].(*provisioner.ACME)
require.True(t, ok)
assert.Equals(t, "JWK", acme.Type)
}

func TestProvisionerToCertificates_missingDetails(t *testing.T) {
tests := map[string]*linkedca.ProvisionerDetails{
"missing details": nil,
"missing oneof": {},
"nil oneof value": {
Data: &linkedca.ProvisionerDetails_AWS{},
},
}

for name, details := range tests {
t.Run(name, func(t *testing.T) {
p := &linkedca.Provisioner{
Name: name,
Type: linkedca.Provisioner_AWS,
Details: details,
}
if _, err := ProvisionerToCertificates(p); err == nil {
t.Fatal("ProvisionerToCertificates() error = nil, want missing details error")
}
})
}
}

func Test_wrapRAProvisioner(t *testing.T) {
type args struct {
p provisioner.Interface
Expand Down Expand Up @@ -390,3 +471,55 @@ func Test_isRAProvisioner(t *testing.T) {
})
}
}

func TestAuthority_ProvisionerConversionErrorsAreBadRequests(t *testing.T) {
methods := map[string]func(*Authority, *linkedca.Provisioner) error{
"store": func(a *Authority, p *linkedca.Provisioner) error {
return a.StoreProvisioner(context.Background(), p)
},
"update": func(a *Authority, p *linkedca.Provisioner) error {
return a.UpdateProvisioner(context.Background(), p)
},
}

for name, method := range methods {
t.Run(name, func(t *testing.T) {
a := testAuthority(t)
p := &linkedca.Provisioner{
Name: "mismatch",
Type: linkedca.Provisioner_JWK,
Details: &linkedca.ProvisionerDetails{Data: &linkedca.ProvisionerDetails_ACME{
ACME: &linkedca.ACMEProvisioner{},
}},
}

err := method(a, p)
var adminErr *admin.Error
if !errors.As(err, &adminErr) {
t.Fatalf("expected *admin.Error, got %T: %v", err, err)
}
assert.Equals(t, admin.ErrorBadRequestType.String(), adminErr.Type)
assert.Equals(t, http.StatusBadRequest, adminErr.Status)
})
}
}

func TestAuthority_UpdateProvisioner_invalidConfiguration(t *testing.T) {
a := testAuthority(t)
// ClientId is empty, so provisioner.OIDC.Init fails before any I/O or
// DB access; the error must surface as a 400, not a 500.
nu := &linkedca.Provisioner{
Name: "bad-oidc",
Type: linkedca.Provisioner_OIDC,
Details: &linkedca.ProvisionerDetails{Data: &linkedca.ProvisionerDetails_OIDC{
OIDC: &linkedca.OIDCProvisioner{ConfigurationEndpoint: "https://example.com"},
}},
}
err := a.UpdateProvisioner(context.Background(), nu)
var adminErr *admin.Error
if !errors.As(err, &adminErr) {
t.Fatalf("expected *admin.Error, got %T: %v", err, err)
}
assert.Equals(t, admin.ErrorBadRequestType.String(), adminErr.Type)
assert.Equals(t, 400, adminErr.Status)
}