Add authority policy API

This commit is contained in:
Herman Slatman 2022-03-30 14:21:39 +02:00
parent 23676d3bcc
commit 0e052fe299
No known key found for this signature in database
GPG key ID: F4D8A44EA0A75A4F
9 changed files with 284 additions and 129 deletions

View file

@ -35,7 +35,7 @@ type GetExternalAccountKeysResponse struct {
// requireEABEnabled is a middleware that ensures ACME EAB is enabled
// before serving requests that act on ACME EAB credentials.
func (h *Handler) requireEABEnabled(next nextHTTP) nextHTTP {
func (h *Handler) requireEABEnabled(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
provName := chi.URLParam(r, "provisionerName")

View file

@ -12,12 +12,15 @@ import (
"testing"
"github.com/go-chi/chi"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"go.step.sm/linkedca"
"github.com/smallstep/assert"
"github.com/smallstep/certificates/authority/admin"
"github.com/smallstep/certificates/authority/provisioner"
"go.step.sm/linkedca"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
)
func readProtoJSON(r io.ReadCloser, m proto.Message) error {
@ -34,7 +37,7 @@ func TestHandler_requireEABEnabled(t *testing.T) {
ctx context.Context
adminDB admin.DB
auth adminAuthority
next nextHTTP
next http.HandlerFunc
err *admin.Error
statusCode int
}

View file

@ -1,6 +1,8 @@
package api
import (
"net/http"
"github.com/smallstep/certificates/acme"
"github.com/smallstep/certificates/api"
"github.com/smallstep/certificates/authority/admin"
@ -29,19 +31,19 @@ func NewHandler(auth adminAuthority, adminDB admin.DB, acmeDB acme.DB, acmeRespo
// Route traffic and implement the Router interface.
func (h *Handler) Route(r api.Router) {
authnz := func(next nextHTTP) nextHTTP {
authnz := func(next http.HandlerFunc) http.HandlerFunc {
return h.extractAuthorizeTokenAdmin(h.requireAPIEnabled(next))
}
requireEABEnabled := func(next nextHTTP) nextHTTP {
requireEABEnabled := func(next http.HandlerFunc) http.HandlerFunc {
return h.requireEABEnabled(next)
}
enabledInStandalone := func(next nextHTTP) nextHTTP {
enabledInStandalone := func(next http.HandlerFunc) http.HandlerFunc {
return h.checkAction(next, true)
}
disabledInStandalone := func(next nextHTTP) nextHTTP {
disabledInStandalone := func(next http.HandlerFunc) http.HandlerFunc {
return h.checkAction(next, false)
}
@ -73,10 +75,10 @@ func (h *Handler) Route(r api.Router) {
// Policy - Provisioner
//r.MethodFunc("GET", "/provisioners/{name}/policy", noauth(h.policyResponder.GetProvisionerPolicy))
r.MethodFunc("GET", "/provisioners/{name}/policy", authnz(disabledInStandalone(h.policyResponder.GetProvisionerPolicy)))
r.MethodFunc("POST", "/provisioners/{name}/policy", authnz(disabledInStandalone(h.policyResponder.CreateProvisionerPolicy)))
r.MethodFunc("PUT", "/provisioners/{name}/policy", authnz(disabledInStandalone(h.policyResponder.UpdateProvisionerPolicy)))
r.MethodFunc("DELETE", "/provisioners/{name}/policy", authnz(disabledInStandalone(h.policyResponder.DeleteProvisionerPolicy)))
r.MethodFunc("GET", "/provisioners/{provisionerName}/policy", authnz(disabledInStandalone(h.loadProvisionerByName(h.policyResponder.GetProvisionerPolicy))))
r.MethodFunc("POST", "/provisioners/{provisionerName}/policy", authnz(disabledInStandalone(h.loadProvisionerByName(h.policyResponder.CreateProvisionerPolicy))))
r.MethodFunc("PUT", "/provisioners/{provisionerName}/policy", authnz(disabledInStandalone(h.loadProvisionerByName(h.policyResponder.UpdateProvisionerPolicy))))
r.MethodFunc("DELETE", "/provisioners/{provisionerName}/policy", authnz(disabledInStandalone(h.loadProvisionerByName(h.policyResponder.DeleteProvisionerPolicy))))
// Policy - ACME Account
// TODO: ensure we don't clash with eab; might want to change eab paths slightly (as long as we don't have it released completely; needs changes in adminClient too)

View file

@ -5,16 +5,17 @@ import (
"go.step.sm/linkedca"
"github.com/go-chi/chi"
"github.com/smallstep/certificates/api"
"github.com/smallstep/certificates/authority/admin"
"github.com/smallstep/certificates/authority/admin/db/nosql"
"github.com/smallstep/certificates/authority/provisioner"
)
type nextHTTP = func(http.ResponseWriter, *http.Request)
// requireAPIEnabled is a middleware that ensures the Administration API
// is enabled before servicing requests.
func (h *Handler) requireAPIEnabled(next nextHTTP) nextHTTP {
func (h *Handler) requireAPIEnabled(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !h.auth.IsAdminAPIEnabled() {
api.WriteError(w, admin.NewError(admin.ErrorNotImplementedType,
@ -26,7 +27,7 @@ func (h *Handler) requireAPIEnabled(next nextHTTP) nextHTTP {
}
// extractAuthorizeTokenAdmin is a middleware that extracts and caches the bearer token.
func (h *Handler) extractAuthorizeTokenAdmin(next nextHTTP) nextHTTP {
func (h *Handler) extractAuthorizeTokenAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tok := r.Header.Get("Authorization")
@ -47,8 +48,35 @@ func (h *Handler) extractAuthorizeTokenAdmin(next nextHTTP) nextHTTP {
}
}
// loadProvisioner is a middleware that searches for a provisioner
// by name and stores it in the context.
func (h *Handler) loadProvisionerByName(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
name := chi.URLParam(r, "provisionerName")
var (
p provisioner.Interface
err error
)
if p, err = h.auth.LoadProvisionerByName(name); err != nil {
api.WriteError(w, admin.WrapErrorISE(err, "error loading provisioner %s", name))
return
}
prov, err := h.adminDB.GetProvisioner(ctx, p.GetID())
if err != nil {
api.WriteError(w, err)
return
}
ctx = linkedca.NewContextWithProvisioner(ctx, prov)
next(w, r.WithContext(ctx))
}
}
// checkAction checks if an action is supported in standalone or not
func (h *Handler) checkAction(next nextHTTP, supportedInStandalone bool) nextHTTP {
func (h *Handler) checkAction(next http.HandlerFunc, supportedInStandalone bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// actions allowed in standalone mode are always supported

View file

@ -12,17 +12,19 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"google.golang.org/protobuf/types/known/timestamppb"
"go.step.sm/linkedca"
"github.com/smallstep/assert"
"github.com/smallstep/certificates/authority/admin"
"go.step.sm/linkedca"
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestHandler_requireAPIEnabled(t *testing.T) {
type test struct {
ctx context.Context
auth adminAuthority
next nextHTTP
next http.HandlerFunc
err *admin.Error
statusCode int
}
@ -102,7 +104,7 @@ func TestHandler_extractAuthorizeTokenAdmin(t *testing.T) {
ctx context.Context
auth adminAuthority
req *http.Request
next nextHTTP
next http.HandlerFunc
err *admin.Error
statusCode int
}

View file

@ -3,14 +3,11 @@ package api
import (
"net/http"
"github.com/go-chi/chi"
"go.step.sm/linkedca"
"github.com/smallstep/certificates/api"
"github.com/smallstep/certificates/api/read"
"github.com/smallstep/certificates/authority/admin"
"github.com/smallstep/certificates/authority/provisioner"
)
type policyAdminResponderInterface interface {
@ -54,7 +51,7 @@ func (par *PolicyAdminResponder) GetAuthorityPolicy(w http.ResponseWriter, r *ht
}
if policy == nil {
api.JSONNotFound(w)
api.WriteError(w, admin.NewError(admin.ErrorNotFoundType, "authority policy does not exist"))
return
}
@ -117,7 +114,7 @@ func (par *PolicyAdminResponder) UpdateAuthorityPolicy(w http.ResponseWriter, r
}
if policy == nil {
api.JSONNotFound(w)
api.WriteError(w, admin.NewError(admin.ErrorNotFoundType, "authority policy does not exist"))
return
}
@ -152,7 +149,7 @@ func (par *PolicyAdminResponder) DeleteAuthorityPolicy(w http.ResponseWriter, r
}
if policy == nil {
api.JSONNotFound(w)
api.WriteError(w, admin.NewError(admin.ErrorNotFoundType, "authority policy does not exist"))
return
}
@ -167,27 +164,12 @@ func (par *PolicyAdminResponder) DeleteAuthorityPolicy(w http.ResponseWriter, r
// GetProvisionerPolicy handles the GET /admin/provisioners/{name}/policy request
func (par *PolicyAdminResponder) GetProvisionerPolicy(w http.ResponseWriter, r *http.Request) {
// TODO: move getting provisioner to middleware?
ctx := r.Context()
name := chi.URLParam(r, "name")
var (
p provisioner.Interface
err error
)
if p, err = par.auth.LoadProvisionerByName(name); err != nil {
api.WriteError(w, admin.WrapErrorISE(err, "error loading provisioner %s", name))
return
}
prov, err := par.adminDB.GetProvisioner(ctx, p.GetID())
if err != nil {
api.WriteError(w, err)
return
}
prov := linkedca.ProvisionerFromContext(r.Context())
policy := prov.GetPolicy()
if policy == nil {
api.JSONNotFound(w)
api.WriteError(w, admin.NewError(admin.ErrorNotFoundType, "provisioner policy does not exist"))
return
}
@ -196,41 +178,28 @@ func (par *PolicyAdminResponder) GetProvisionerPolicy(w http.ResponseWriter, r *
// CreateProvisionerPolicy handles the POST /admin/provisioners/{name}/policy request
func (par *PolicyAdminResponder) CreateProvisionerPolicy(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
name := chi.URLParam(r, "name")
var (
p provisioner.Interface
err error
)
if p, err = par.auth.LoadProvisionerByName(name); err != nil {
api.WriteError(w, admin.WrapErrorISE(err, "error loading provisioner %s", name))
return
}
prov, err := par.adminDB.GetProvisioner(ctx, p.GetID())
if err != nil {
api.WriteError(w, err)
return
}
ctx := r.Context()
prov := linkedca.ProvisionerFromContext(ctx)
policy := prov.GetPolicy()
if policy != nil {
adminErr := admin.NewError(admin.ErrorBadRequestType, "provisioner %s already has a policy", name)
adminErr := admin.NewError(admin.ErrorBadRequestType, "provisioner %s already has a policy", prov.Name)
adminErr.Status = http.StatusConflict
api.WriteError(w, adminErr)
return
}
var newPolicy = new(linkedca.Policy)
if err := read.ProtoJSON(r.Body, newPolicy); err != nil {
api.WriteError(w, err)
if !api.ReadProtoJSONWithCheck(w, r.Body, newPolicy) {
return
}
prov.Policy = newPolicy
err = par.auth.UpdateProvisioner(ctx, prov)
err := par.auth.UpdateProvisioner(ctx, prov)
if err != nil {
api.WriteError(w, err)
api.WriteError(w, admin.WrapError(admin.ErrorBadRequestType, err, "error creating provisioner policy"))
return
}
@ -239,88 +208,65 @@ func (par *PolicyAdminResponder) CreateProvisionerPolicy(w http.ResponseWriter,
// UpdateProvisionerPolicy handles the PUT /admin/provisioners/{name}/policy request
func (par *PolicyAdminResponder) UpdateProvisionerPolicy(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
name := chi.URLParam(r, "name")
var (
p provisioner.Interface
err error
)
if p, err = par.auth.LoadProvisionerByName(name); err != nil {
api.WriteError(w, admin.WrapErrorISE(err, "error loading provisioner %s", name))
prov := linkedca.ProvisionerFromContext(ctx)
if prov.Policy == nil {
api.WriteError(w, admin.NewError(admin.ErrorNotFoundType, "provisioner policy does not exist"))
return
}
prov, err := par.adminDB.GetProvisioner(ctx, p.GetID())
var newPolicy = new(linkedca.Policy)
if !api.ReadProtoJSONWithCheck(w, r.Body, newPolicy) {
return
}
prov.Policy = newPolicy
err := par.auth.UpdateProvisioner(ctx, prov)
if err != nil {
api.WriteError(w, err)
api.WriteError(w, admin.WrapError(admin.ErrorBadRequestType, err, "error updating provisioner policy"))
return
}
var policy = new(linkedca.Policy)
if err := read.ProtoJSON(r.Body, policy); err != nil {
api.WriteError(w, err)
return
}
prov.Policy = policy
err = par.auth.UpdateProvisioner(ctx, prov)
if err != nil {
api.WriteError(w, err)
return
}
api.ProtoJSONStatus(w, policy, http.StatusOK)
api.ProtoJSONStatus(w, newPolicy, http.StatusOK)
}
// DeleteProvisionerPolicy handles the DELETE /admin/provisioners/{name}/policy request
func (par *PolicyAdminResponder) DeleteProvisionerPolicy(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
name := chi.URLParam(r, "name")
var (
p provisioner.Interface
err error
)
if p, err = par.auth.LoadProvisionerByName(name); err != nil {
api.WriteError(w, admin.WrapErrorISE(err, "error loading provisioner %s", name))
return
}
prov, err := par.adminDB.GetProvisioner(ctx, p.GetID())
if err != nil {
api.WriteError(w, err)
return
}
prov := linkedca.ProvisionerFromContext(ctx)
if prov.Policy == nil {
api.JSONNotFound(w)
api.WriteError(w, admin.NewError(admin.ErrorNotFoundType, "provisioner policy does not exist"))
return
}
// remove the policy
prov.Policy = nil
err = par.auth.UpdateProvisioner(ctx, prov)
err := par.auth.UpdateProvisioner(ctx, prov)
if err != nil {
api.WriteError(w, err)
return
}
api.JSON(w, &DeleteResponse{Status: "ok"})
api.JSONStatus(w, DeleteResponse{Status: "ok"}, http.StatusOK)
}
func (par *PolicyAdminResponder) GetACMEAccountPolicy(w http.ResponseWriter, r *http.Request) {
api.JSON(w, "ok")
api.JSON(w, "not implemented yet")
}
func (par *PolicyAdminResponder) CreateACMEAccountPolicy(w http.ResponseWriter, r *http.Request) {
api.JSON(w, "ok")
api.JSON(w, "not implemented yet")
}
func (par *PolicyAdminResponder) UpdateACMEAccountPolicy(w http.ResponseWriter, r *http.Request) {
api.JSON(w, "ok")
api.JSON(w, "not implemented yet")
}
func (par *PolicyAdminResponder) DeleteACMEAccountPolicy(w http.ResponseWriter, r *http.Request) {
api.JSON(w, "ok")
api.JSON(w, "not implemented yet")
}

View file

@ -218,6 +218,7 @@ func (a *Authority) reloadPolicyEngines(ctx context.Context) error {
err error
policyOptions *policy.Options
)
// if admin API is enabled, the CA is running in linked mode
if a.config.AuthorityConfig.EnableAdmin {
linkedPolicy, err := a.adminDB.GetAuthorityPolicy(ctx)
if err != nil {

View file

@ -77,6 +77,8 @@ func (a *Authority) RemoveAuthorityPolicy(ctx context.Context) error {
return nil
}
// checkPolicy checks if a new or updated policy configuration results in the user
// locking themselves or other admins out of the CA.
func (a *Authority) checkPolicy(ctx context.Context, adm *linkedca.Admin, p *linkedca.Policy) error {
// convert the policy; return early if nil
@ -90,9 +92,15 @@ func (a *Authority) checkPolicy(ctx context.Context, adm *linkedca.Admin, p *lin
return admin.WrapErrorISE(err, "error creating temporary policy engine")
}
// when an empty policy is provided, the resulting engine is nil
// and there's no policy to evaluate.
if engine == nil {
return nil
}
// TODO(hs): Provide option to force the policy, even when the admin subject would be locked out?
sans := []string{adm.Subject}
sans := []string{adm.GetSubject()}
if err := isAllowed(engine, sans); err != nil {
return err
}
@ -151,44 +159,80 @@ func policyToCertificates(p *linkedca.Policy) *authPolicy.Options {
// fill x509 policy configuration
if p.X509 != nil {
if p.X509.Allow != nil {
if p.X509.Allow.Dns != nil {
opts.X509.AllowedNames.DNSDomains = p.X509.Allow.Dns
}
if p.X509.Allow.Ips != nil {
opts.X509.AllowedNames.IPRanges = p.X509.Allow.Ips
}
if p.X509.Allow.Emails != nil {
opts.X509.AllowedNames.EmailAddresses = p.X509.Allow.Emails
}
if p.X509.Allow.Uris != nil {
opts.X509.AllowedNames.URIDomains = p.X509.Allow.Uris
}
}
if p.X509.Deny != nil {
if p.X509.Deny.Dns != nil {
opts.X509.DeniedNames.DNSDomains = p.X509.Deny.Dns
}
if p.X509.Deny.Ips != nil {
opts.X509.DeniedNames.IPRanges = p.X509.Deny.Ips
}
if p.X509.Deny.Emails != nil {
opts.X509.DeniedNames.EmailAddresses = p.X509.Deny.Emails
}
if p.X509.Deny.Uris != nil {
opts.X509.DeniedNames.URIDomains = p.X509.Deny.Uris
}
}
}
// fill ssh policy configuration
if p.Ssh != nil {
if p.Ssh.Host != nil {
if p.Ssh.Host.Allow != nil {
if p.Ssh.Host.Allow.Dns != nil {
opts.SSH.Host.AllowedNames.DNSDomains = p.Ssh.Host.Allow.Dns
}
if p.Ssh.Host.Allow.Ips != nil {
opts.SSH.Host.AllowedNames.IPRanges = p.Ssh.Host.Allow.Ips
opts.SSH.Host.AllowedNames.EmailAddresses = p.Ssh.Host.Allow.Principals
}
if p.Ssh.Host.Allow.Principals != nil {
opts.SSH.Host.AllowedNames.Principals = p.Ssh.Host.Allow.Principals
}
}
if p.Ssh.Host.Deny != nil {
if p.Ssh.Host.Deny.Dns != nil {
opts.SSH.Host.DeniedNames.DNSDomains = p.Ssh.Host.Deny.Dns
}
if p.Ssh.Host.Deny.Ips != nil {
opts.SSH.Host.DeniedNames.IPRanges = p.Ssh.Host.Deny.Ips
}
if p.Ssh.Host.Deny.Principals != nil {
opts.SSH.Host.DeniedNames.Principals = p.Ssh.Host.Deny.Principals
}
}
}
if p.Ssh.User != nil {
if p.Ssh.User.Allow != nil {
if p.Ssh.User.Allow.Emails != nil {
opts.SSH.User.AllowedNames.EmailAddresses = p.Ssh.User.Allow.Emails
}
if p.Ssh.User.Allow.Principals != nil {
opts.SSH.User.AllowedNames.Principals = p.Ssh.User.Allow.Principals
}
}
if p.Ssh.User.Deny != nil {
if p.Ssh.User.Deny.Emails != nil {
opts.SSH.User.DeniedNames.EmailAddresses = p.Ssh.User.Deny.Emails
}
if p.Ssh.User.Deny.Principals != nil {
opts.SSH.User.DeniedNames.Principals = p.Ssh.User.Deny.Principals
}
}
}
}
return opts
}

View file

@ -4,6 +4,7 @@ import (
"bytes"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
@ -679,6 +680,134 @@ retry:
return nil
}
func (c *AdminClient) GetAuthorityPolicy() (*linkedca.Policy, error) {
var retried bool
u := c.endpoint.ResolveReference(&url.URL{Path: path.Join(adminURLPrefix, "policy")})
tok, err := c.generateAdminToken(u.Path)
if err != nil {
return nil, fmt.Errorf("error generating admin token: %w", err)
}
req, err := http.NewRequest(http.MethodGet, u.String(), http.NoBody)
if err != nil {
return nil, fmt.Errorf("creating GET %s request failed: %w", u, err)
}
req.Header.Add("Authorization", tok)
retry:
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("client GET %s failed: %w", u, err)
}
if resp.StatusCode >= 400 {
if !retried && c.retryOnError(resp) {
retried = true
goto retry
}
return nil, readAdminError(resp.Body)
}
var policy = new(linkedca.Policy)
if err := readProtoJSON(resp.Body, policy); err != nil {
return nil, fmt.Errorf("error reading %s: %w", u, err)
}
return policy, nil
}
func (c *AdminClient) CreateAuthorityPolicy(p *linkedca.Policy) (*linkedca.Policy, error) {
var retried bool
body, err := protojson.Marshal(p)
if err != nil {
return nil, fmt.Errorf("error marshaling request: %w", err)
}
u := c.endpoint.ResolveReference(&url.URL{Path: path.Join(adminURLPrefix, "policy")})
tok, err := c.generateAdminToken(u.Path)
if err != nil {
return nil, fmt.Errorf("error generating admin token: %w", err)
}
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)
retry:
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 policy = new(linkedca.Policy)
if err := readProtoJSON(resp.Body, policy); err != nil {
return nil, fmt.Errorf("error reading %s: %w", u, err)
}
return policy, nil
}
func (c *AdminClient) UpdateAuthorityPolicy(p *linkedca.Policy) (*linkedca.Policy, error) {
var retried bool
body, err := protojson.Marshal(p)
if err != nil {
return nil, fmt.Errorf("error marshaling request: %w", err)
}
u := c.endpoint.ResolveReference(&url.URL{Path: path.Join(adminURLPrefix, "policy")})
tok, err := c.generateAdminToken(u.Path)
if err != nil {
return nil, fmt.Errorf("error generating admin token: %w", err)
}
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)
retry:
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 policy = new(linkedca.Policy)
if err := readProtoJSON(resp.Body, policy); err != nil {
return nil, fmt.Errorf("error reading %s: %w", u, err)
}
return policy, nil
}
func (c *AdminClient) RemoveAuthorityPolicy() error {
var retried bool
u := c.endpoint.ResolveReference(&url.URL{Path: path.Join(adminURLPrefix, "policy")})
tok, err := c.generateAdminToken(u.Path)
if err != nil {
return fmt.Errorf("error generating admin token: %w", err)
}
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)
retry:
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()