Remove 'RETURNING' functionality from MultiInserter (#7740)

Deprecate the "InsertAuthzsIndividually" feature flag, which has been
set to true in both Staging and Production. Delete the code guarded
behind that flag being false, namely the ability of the MultiInserter to
return the newly-created IDs from all of the rows it has inserted. This
behavior is being removed because it is not supported in MySQL / Vitess.

Fixes https://github.com/letsencrypt/boulder/issues/7718

---

> [!WARNING]
> ~~Do not merge until IN-10737 is complete~~
This commit is contained in:
Aaron Gable 2025-02-19 14:37:22 -08:00 committed by GitHub
parent 212a66ab49
commit d9433fe293
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 67 additions and 223 deletions

View File

@ -58,17 +58,9 @@ type Executor interface {
OneSelector OneSelector
Inserter Inserter
SelectExecer SelectExecer
Queryer
Delete(context.Context, ...interface{}) (int64, error) Delete(context.Context, ...interface{}) (int64, error)
Get(context.Context, interface{}, ...interface{}) (interface{}, error) Get(context.Context, interface{}, ...interface{}) (interface{}, error)
Update(context.Context, ...interface{}) (int64, error) Update(context.Context, ...interface{}) (int64, error)
}
// Queryer offers the QueryContext method. Note that this is not read-only (i.e. not
// Selector), since a QueryContext can be `INSERT`, `UPDATE`, etc. The difference
// between QueryContext and ExecContext is that QueryContext can return rows. So for instance it is
// suitable for inserting rows and getting back ids.
type Queryer interface {
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
} }

View File

@ -7,7 +7,7 @@ import (
) )
// MultiInserter makes it easy to construct a // MultiInserter makes it easy to construct a
// `INSERT INTO table (...) VALUES ... RETURNING id;` // `INSERT INTO table (...) VALUES ...;`
// query which inserts multiple rows into the same table. It can also execute // query which inserts multiple rows into the same table. It can also execute
// the resulting query. // the resulting query.
type MultiInserter struct { type MultiInserter struct {
@ -16,20 +16,15 @@ type MultiInserter struct {
// https://mariadb.com/kb/en/identifier-names/#unquoted // https://mariadb.com/kb/en/identifier-names/#unquoted
table string table string
fields []string fields []string
returningColumn string
values [][]interface{} values [][]interface{}
} }
// NewMultiInserter creates a new MultiInserter, checking for reasonable table // NewMultiInserter creates a new MultiInserter, checking for reasonable table
// name and list of fields. returningColumn is the name of a column to be used // name and list of fields.
// in a `RETURNING xyz` clause at the end. If it is empty, no `RETURNING xyz` // Safety: `table` and `fields` must contain only strings that are known at
// clause is used. If returningColumn is present, it must refer to a column // compile time. They must not contain user-controlled strings.
// that can be parsed into an int64. func NewMultiInserter(table string, fields []string) (*MultiInserter, error) {
// Safety: `table`, `fields`, and `returningColumn` must contain only strings
// that are known at compile time. They must not contain user-controlled
// strings.
func NewMultiInserter(table string, fields []string, returningColumn string) (*MultiInserter, error) {
if len(table) == 0 || len(fields) == 0 { if len(table) == 0 || len(fields) == 0 {
return nil, fmt.Errorf("empty table name or fields list") return nil, fmt.Errorf("empty table name or fields list")
} }
@ -44,17 +39,10 @@ func NewMultiInserter(table string, fields []string, returningColumn string) (*M
return nil, err return nil, err
} }
} }
if returningColumn != "" {
err := validMariaDBUnquotedIdentifier(returningColumn)
if err != nil {
return nil, err
}
}
return &MultiInserter{ return &MultiInserter{
table: table, table: table,
fields: fields, fields: fields,
returningColumn: returningColumn,
values: make([][]interface{}, 0), values: make([][]interface{}, 0),
}, nil }, nil
} }
@ -84,56 +72,32 @@ func (mi *MultiInserter) query() (string, []interface{}) {
questions := strings.TrimRight(questionsBuf.String(), ",") questions := strings.TrimRight(questionsBuf.String(), ",")
// Safety: we are interpolating `mi.returningColumn` into an SQL query. We
// know it is a valid unquoted identifier in MariaDB because we verified
// that in the constructor.
returning := ""
if mi.returningColumn != "" {
returning = fmt.Sprintf(" RETURNING %s", mi.returningColumn)
}
// Safety: we are interpolating `mi.table` and `mi.fields` into an SQL // Safety: we are interpolating `mi.table` and `mi.fields` into an SQL
// query. We know they contain, respectively, a valid unquoted identifier // query. We know they contain, respectively, a valid unquoted identifier
// and a slice of valid unquoted identifiers because we verified that in // and a slice of valid unquoted identifiers because we verified that in
// the constructor. We know the query overall has valid syntax because we // the constructor. We know the query overall has valid syntax because we
// generate it entirely within this function. // generate it entirely within this function.
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES %s%s", mi.table, strings.Join(mi.fields, ","), questions, returning) query := fmt.Sprintf("INSERT INTO %s (%s) VALUES %s", mi.table, strings.Join(mi.fields, ","), questions)
return query, queryArgs return query, queryArgs
} }
// Insert inserts all the collected rows into the database represented by // Insert inserts all the collected rows into the database represented by
// `queryer`. If a non-empty returningColumn was provided, then it returns // `queryer`.
// the list of values from that column returned by the query. func (mi *MultiInserter) Insert(ctx context.Context, db Execer) error {
func (mi *MultiInserter) Insert(ctx context.Context, queryer Queryer) ([]int64, error) {
query, queryArgs := mi.query() query, queryArgs := mi.query()
rows, err := queryer.QueryContext(ctx, query, queryArgs...) res, err := db.ExecContext(ctx, query, queryArgs...)
if err != nil { if err != nil {
return nil, err return err
} }
ids := make([]int64, 0, len(mi.values)) affected, err := res.RowsAffected()
if mi.returningColumn != "" {
for rows.Next() {
var id int64
err = rows.Scan(&id)
if err != nil { if err != nil {
rows.Close() return err
return nil, err
}
ids = append(ids, id)
} }
if affected != int64(len(mi.values)) {
return fmt.Errorf("unexpected number of rows inserted: %d != %d", affected, len(mi.values))
} }
// Hack: sometimes in unittests we make a mock Queryer that returns a nil return nil
// `*sql.Rows`. A nil `*sql.Rows` is not actually valid— calling `Close()`
// on it will panic— but here we choose to treat it like an empty list,
// and skip calling `Close()` to avoid the panic.
if rows != nil {
err = rows.Close()
if err != nil {
return nil, err
}
}
return ids, nil
} }

View File

@ -7,34 +7,29 @@ import (
) )
func TestNewMulti(t *testing.T) { func TestNewMulti(t *testing.T) {
_, err := NewMultiInserter("", []string{"colA"}, "") _, err := NewMultiInserter("", []string{"colA"})
test.AssertError(t, err, "Empty table name should fail") test.AssertError(t, err, "Empty table name should fail")
_, err = NewMultiInserter("myTable", nil, "") _, err = NewMultiInserter("myTable", nil)
test.AssertError(t, err, "Empty fields list should fail") test.AssertError(t, err, "Empty fields list should fail")
mi, err := NewMultiInserter("myTable", []string{"colA"}, "") mi, err := NewMultiInserter("myTable", []string{"colA"})
test.AssertNotError(t, err, "Single-column construction should not fail") test.AssertNotError(t, err, "Single-column construction should not fail")
test.AssertEquals(t, len(mi.fields), 1) test.AssertEquals(t, len(mi.fields), 1)
mi, err = NewMultiInserter("myTable", []string{"colA", "colB", "colC"}, "") mi, err = NewMultiInserter("myTable", []string{"colA", "colB", "colC"})
test.AssertNotError(t, err, "Multi-column construction should not fail") test.AssertNotError(t, err, "Multi-column construction should not fail")
test.AssertEquals(t, len(mi.fields), 3) test.AssertEquals(t, len(mi.fields), 3)
_, err = NewMultiInserter("", []string{"colA"}, "colB") _, err = NewMultiInserter("foo\"bar", []string{"colA"})
test.AssertError(t, err, "expected error for empty table name")
_, err = NewMultiInserter("foo\"bar", []string{"colA"}, "colB")
test.AssertError(t, err, "expected error for invalid table name") test.AssertError(t, err, "expected error for invalid table name")
_, err = NewMultiInserter("myTable", []string{"colA", "foo\"bar"}, "colB") _, err = NewMultiInserter("myTable", []string{"colA", "foo\"bar"})
test.AssertError(t, err, "expected error for invalid column name") test.AssertError(t, err, "expected error for invalid column name")
_, err = NewMultiInserter("myTable", []string{"colA"}, "foo\"bar")
test.AssertError(t, err, "expected error for invalid returning column name")
} }
func TestMultiAdd(t *testing.T) { func TestMultiAdd(t *testing.T) {
mi, err := NewMultiInserter("table", []string{"a", "b", "c"}, "") mi, err := NewMultiInserter("table", []string{"a", "b", "c"})
test.AssertNotError(t, err, "Failed to create test MultiInserter") test.AssertNotError(t, err, "Failed to create test MultiInserter")
err = mi.Add([]interface{}{}) err = mi.Add([]interface{}{})
@ -57,7 +52,7 @@ func TestMultiAdd(t *testing.T) {
} }
func TestMultiQuery(t *testing.T) { func TestMultiQuery(t *testing.T) {
mi, err := NewMultiInserter("table", []string{"a", "b", "c"}, "") mi, err := NewMultiInserter("table", []string{"a", "b", "c"})
test.AssertNotError(t, err, "Failed to create test MultiInserter") test.AssertNotError(t, err, "Failed to create test MultiInserter")
err = mi.Add([]interface{}{"one", "two", "three"}) err = mi.Add([]interface{}{"one", "two", "three"})
test.AssertNotError(t, err, "Failed to insert test row") test.AssertNotError(t, err, "Failed to insert test row")
@ -67,15 +62,4 @@ func TestMultiQuery(t *testing.T) {
query, queryArgs := mi.query() query, queryArgs := mi.query()
test.AssertEquals(t, query, "INSERT INTO table (a,b,c) VALUES (?,?,?),(?,?,?)") test.AssertEquals(t, query, "INSERT INTO table (a,b,c) VALUES (?,?,?),(?,?,?)")
test.AssertDeepEquals(t, queryArgs, []interface{}{"one", "two", "three", "egy", "kettö", "három"}) test.AssertDeepEquals(t, queryArgs, []interface{}{"one", "two", "three", "egy", "kettö", "három"})
mi, err = NewMultiInserter("table", []string{"a", "b", "c"}, "id")
test.AssertNotError(t, err, "Failed to create test MultiInserter")
err = mi.Add([]interface{}{"one", "two", "three"})
test.AssertNotError(t, err, "Failed to insert test row")
err = mi.Add([]interface{}{"egy", "kettö", "három"})
test.AssertNotError(t, err, "Failed to insert test row")
query, queryArgs = mi.query()
test.AssertEquals(t, query, "INSERT INTO table (a,b,c) VALUES (?,?,?),(?,?,?) RETURNING id")
test.AssertDeepEquals(t, queryArgs, []interface{}{"one", "two", "three", "egy", "kettö", "három"})
} }

View File

@ -20,6 +20,7 @@ type Config struct {
UseKvLimitsForNewOrder bool UseKvLimitsForNewOrder bool
DisableLegacyLimitWrites bool DisableLegacyLimitWrites bool
MultipleCertificateProfiles bool MultipleCertificateProfiles bool
InsertAuthzsIndividually bool
// ServeRenewalInfo exposes the renewalInfo endpoint in the directory and for // ServeRenewalInfo exposes the renewalInfo endpoint in the directory and for
// GET requests. WARNING: This feature is a draft and highly unstable. // GET requests. WARNING: This feature is a draft and highly unstable.
@ -71,13 +72,6 @@ type Config struct {
// queries waiting for an available connection may be cancelled. // queries waiting for an available connection may be cancelled.
PropagateCancels bool PropagateCancels bool
// InsertAuthzsIndividually causes the SA's NewOrderAndAuthzs method to
// create each new authz one at a time, rather than using MultiInserter.
// Although this is expected to be a performance penalty, it is necessary to
// get the AUTO_INCREMENT ID of each new authz without relying on MariaDB's
// unique "INSERT ... RETURNING" functionality.
InsertAuthzsIndividually bool
// AutomaticallyPauseZombieClients configures the RA to automatically track // AutomaticallyPauseZombieClients configures the RA to automatically track
// and pause issuance for each (account, hostname) pair that repeatedly // and pause issuance for each (account, hostname) pair that repeatedly
// fails validation. // fails validation.

View File

@ -988,12 +988,12 @@ func deleteOrderFQDNSet(
return nil return nil
} }
func addIssuedNames(ctx context.Context, queryer db.Queryer, cert *x509.Certificate, isRenewal bool) error { func addIssuedNames(ctx context.Context, queryer db.Execer, cert *x509.Certificate, isRenewal bool) error {
if len(cert.DNSNames) == 0 { if len(cert.DNSNames) == 0 {
return berrors.InternalServerError("certificate has no DNSNames") return berrors.InternalServerError("certificate has no DNSNames")
} }
multiInserter, err := db.NewMultiInserter("issuedNames", []string{"reversedName", "serial", "notBefore", "renewal"}, "") multiInserter, err := db.NewMultiInserter("issuedNames", []string{"reversedName", "serial", "notBefore", "renewal"})
if err != nil { if err != nil {
return err return err
} }
@ -1008,8 +1008,7 @@ func addIssuedNames(ctx context.Context, queryer db.Queryer, cert *x509.Certific
return err return err
} }
} }
_, err = multiInserter.Insert(ctx, queryer) return multiInserter.Insert(ctx, queryer)
return err
} }
func addKeyHash(ctx context.Context, db db.Inserter, cert *x509.Certificate) error { func addKeyHash(ctx context.Context, db db.Inserter, cert *x509.Certificate) error {

View File

@ -7,7 +7,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
"github.com/go-jose/go-jose/v4" "github.com/go-jose/go-jose/v4"
@ -21,7 +20,6 @@ import (
corepb "github.com/letsencrypt/boulder/core/proto" corepb "github.com/letsencrypt/boulder/core/proto"
"github.com/letsencrypt/boulder/db" "github.com/letsencrypt/boulder/db"
berrors "github.com/letsencrypt/boulder/errors" berrors "github.com/letsencrypt/boulder/errors"
"github.com/letsencrypt/boulder/features"
bgrpc "github.com/letsencrypt/boulder/grpc" bgrpc "github.com/letsencrypt/boulder/grpc"
blog "github.com/letsencrypt/boulder/log" blog "github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/revocation" "github.com/letsencrypt/boulder/revocation"
@ -518,8 +516,7 @@ func (ssa *SQLStorageAuthority) NewOrderAndAuthzs(ctx context.Context, req *sapb
output, err := db.WithTransaction(ctx, ssa.dbMap, func(tx db.Executor) (interface{}, error) { output, err := db.WithTransaction(ctx, ssa.dbMap, func(tx db.Executor) (interface{}, error) {
// First, insert all of the new authorizations and record their IDs. // First, insert all of the new authorizations and record their IDs.
newAuthzIDs := make([]int64, 0) newAuthzIDs := make([]int64, 0, len(req.NewAuthzs))
if features.Get().InsertAuthzsIndividually {
for _, authz := range req.NewAuthzs { for _, authz := range req.NewAuthzs {
am, err := newAuthzReqToModel(authz, req.NewOrder.CertificateProfileName) am, err := newAuthzReqToModel(authz, req.NewOrder.CertificateProfileName)
if err != nil { if err != nil {
@ -531,42 +528,6 @@ func (ssa *SQLStorageAuthority) NewOrderAndAuthzs(ctx context.Context, req *sapb
} }
newAuthzIDs = append(newAuthzIDs, am.ID) newAuthzIDs = append(newAuthzIDs, am.ID)
} }
} else {
if len(req.NewAuthzs) != 0 {
inserter, err := db.NewMultiInserter("authz2", strings.Split(authzFields, ", "), "id")
if err != nil {
return nil, err
}
for _, authz := range req.NewAuthzs {
am, err := newAuthzReqToModel(authz, req.NewOrder.CertificateProfileName)
if err != nil {
return nil, err
}
err = inserter.Add([]interface{}{
am.ID,
am.IdentifierType,
am.IdentifierValue,
am.RegistrationID,
am.CertificateProfileName,
statusToUint[core.StatusPending],
am.Expires,
am.Challenges,
nil,
nil,
am.Token,
nil,
nil,
})
if err != nil {
return nil, err
}
}
newAuthzIDs, err = inserter.Insert(ctx, tx)
if err != nil {
return nil, err
}
}
}
// Second, insert the new order. // Second, insert the new order.
created := ssa.clk.Now() created := ssa.clk.Now()
@ -585,7 +546,7 @@ func (ssa *SQLStorageAuthority) NewOrderAndAuthzs(ctx context.Context, req *sapb
// Third, insert all of the orderToAuthz relations. // Third, insert all of the orderToAuthz relations.
// Have to combine the already-associated and newly-created authzs. // Have to combine the already-associated and newly-created authzs.
allAuthzIds := append(req.NewOrder.V2Authorizations, newAuthzIDs...) allAuthzIds := append(req.NewOrder.V2Authorizations, newAuthzIDs...)
inserter, err := db.NewMultiInserter("orderToAuthz2", []string{"orderID", "authzID"}, "") inserter, err := db.NewMultiInserter("orderToAuthz2", []string{"orderID", "authzID"})
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -595,7 +556,7 @@ func (ssa *SQLStorageAuthority) NewOrderAndAuthzs(ctx context.Context, req *sapb
return nil, err return nil, err
} }
} }
_, err = inserter.Insert(ctx, tx) err = inserter.Insert(ctx, tx)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -10,7 +10,6 @@ import (
"crypto/x509" "crypto/x509"
"database/sql" "database/sql"
"encoding/base64" "encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@ -722,15 +721,28 @@ func TestFQDNSetsExists(t *testing.T) {
test.Assert(t, exists.Exists, "FQDN set does exist") test.Assert(t, exists.Exists, "FQDN set does exist")
} }
type queryRecorder struct { type execRecorder struct {
valuesPerRow int
query string query string
args []interface{} args []interface{}
} }
func (e *queryRecorder) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) { func (e *execRecorder) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
e.query = query e.query = query
e.args = args e.args = args
return nil, nil return rowsResult{int64(len(args) / e.valuesPerRow)}, nil
}
type rowsResult struct {
rowsAffected int64
}
func (r rowsResult) LastInsertId() (int64, error) {
return r.rowsAffected, nil
}
func (r rowsResult) RowsAffected() (int64, error) {
return r.rowsAffected, nil
} }
func TestAddIssuedNames(t *testing.T) { func TestAddIssuedNames(t *testing.T) {
@ -813,7 +825,7 @@ func TestAddIssuedNames(t *testing.T) {
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) { t.Run(tc.Name, func(t *testing.T) {
var e queryRecorder e := execRecorder{valuesPerRow: 4}
err := addIssuedNames( err := addIssuedNames(
ctx, ctx,
&e, &e,
@ -891,9 +903,6 @@ func TestNewOrderAndAuthzs(t *testing.T) {
sa, _, cleanup := initSA(t) sa, _, cleanup := initSA(t)
defer cleanup() defer cleanup()
features.Set(features.Config{InsertAuthzsIndividually: true})
defer features.Reset()
reg := createWorkingRegistration(t, sa) reg := createWorkingRegistration(t, sa)
// Insert two pre-existing authorizations to reference // Insert two pre-existing authorizations to reference
@ -948,9 +957,6 @@ func TestNewOrderAndAuthzs_NonNilInnerOrder(t *testing.T) {
sa, fc, cleanup := initSA(t) sa, fc, cleanup := initSA(t)
defer cleanup() defer cleanup()
features.Set(features.Config{InsertAuthzsIndividually: true})
defer features.Reset()
reg := createWorkingRegistration(t, sa) reg := createWorkingRegistration(t, sa)
expires := fc.Now().Add(2 * time.Hour) expires := fc.Now().Add(2 * time.Hour)
@ -972,9 +978,6 @@ func TestNewOrderAndAuthzs_MismatchedRegID(t *testing.T) {
sa, _, cleanup := initSA(t) sa, _, cleanup := initSA(t)
defer cleanup() defer cleanup()
features.Set(features.Config{InsertAuthzsIndividually: true})
defer features.Reset()
_, err := sa.NewOrderAndAuthzs(context.Background(), &sapb.NewOrderAndAuthzsRequest{ _, err := sa.NewOrderAndAuthzs(context.Background(), &sapb.NewOrderAndAuthzsRequest{
NewOrder: &sapb.NewOrderRequest{ NewOrder: &sapb.NewOrderRequest{
RegistrationID: 1, RegistrationID: 1,
@ -993,9 +996,6 @@ func TestNewOrderAndAuthzs_NewAuthzExpectedFields(t *testing.T) {
sa, fc, cleanup := initSA(t) sa, fc, cleanup := initSA(t)
defer cleanup() defer cleanup()
features.Set(features.Config{InsertAuthzsIndividually: true})
defer features.Reset()
reg := createWorkingRegistration(t, sa) reg := createWorkingRegistration(t, sa)
expires := fc.Now().Add(time.Hour) expires := fc.Now().Add(time.Hour)
domain := "a.com" domain := "a.com"
@ -1093,55 +1093,6 @@ func TestNewOrderAndAuthzs_Profile(t *testing.T) {
} }
} }
func BenchmarkNewOrderAndAuthzs(b *testing.B) {
for _, flag := range []bool{false, true} {
for _, numIdents := range []int{1, 2, 5, 10, 20, 50, 100} {
b.Run(fmt.Sprintf("%t/%d", flag, numIdents), func(b *testing.B) {
sa, _, cleanup := initSA(b)
defer cleanup()
if flag {
features.Set(features.Config{InsertAuthzsIndividually: true})
defer features.Reset()
}
reg := createWorkingRegistration(b, sa)
dnsNames := make([]string, 0, numIdents)
newAuthzs := make([]*sapb.NewAuthzRequest, 0, numIdents)
for range numIdents {
var nameBytes [3]byte
_, _ = rand.Read(nameBytes[:])
name := fmt.Sprintf("%s.example.com", hex.EncodeToString(nameBytes[:]))
dnsNames = append(dnsNames, name)
newAuthzs = append(newAuthzs, &sapb.NewAuthzRequest{
RegistrationID: reg.Id,
Identifier: identifier.NewDNS(name).AsProto(),
ChallengeTypes: []string{string(core.ChallengeTypeDNS01)},
Token: core.NewToken(),
Expires: timestamppb.New(sa.clk.Now().Add(24 * time.Hour)),
})
}
b.ResetTimer()
_, err := sa.NewOrderAndAuthzs(context.Background(), &sapb.NewOrderAndAuthzsRequest{
NewOrder: &sapb.NewOrderRequest{
RegistrationID: reg.Id,
Expires: timestamppb.New(sa.clk.Now().Add(24 * time.Hour)),
DnsNames: dnsNames,
},
NewAuthzs: newAuthzs,
})
if err != nil {
b.Error(err)
}
})
}
}
}
func TestSetOrderProcessing(t *testing.T) { func TestSetOrderProcessing(t *testing.T) {
sa, fc, cleanup := initSA(t) sa, fc, cleanup := initSA(t)
defer cleanup() defer cleanup()

View File

@ -48,9 +48,7 @@
} }
}, },
"healthCheckInterval": "4s", "healthCheckInterval": "4s",
"features": { "features": {}
"InsertAuthzsIndividually": true
}
}, },
"syslog": { "syslog": {
"stdoutlevel": 6, "stdoutlevel": 6,

View File

@ -49,7 +49,8 @@
}, },
"features": { "features": {
"UseKvLimitsForNewOrder": true, "UseKvLimitsForNewOrder": true,
"MultipleCertificateProfiles": true "MultipleCertificateProfiles": true,
"InsertAuthzsIndividually": true
} }
}, },
"syslog": { "syslog": {