RA: Allow profile selection to be gated on account-based allow lists (#7959)
Use the new allowlist package added in #7958 to implement an account-based allow list for profile selection in the RA. Part of #7604
This commit is contained in:
parent
2d1f277635
commit
a78efb82b5
|
@ -21,8 +21,13 @@ func NewList[T comparable](members []T) *List[T] {
|
|||
}
|
||||
|
||||
// NewFromYAML reads a YAML sequence of values of type T and returns a *List[T]
|
||||
// containing those values. If the data cannot be parsed, an error is returned.
|
||||
// containing those values. If data is empty, an empty (deny all) list is
|
||||
// returned. If data cannot be parsed, an error is returned.
|
||||
func NewFromYAML[T comparable](data []byte) (*List[T], error) {
|
||||
if len(data) == 0 {
|
||||
return NewList([]T{}), nil
|
||||
}
|
||||
|
||||
var entries []T
|
||||
err := strictyaml.Unmarshal(data, &entries)
|
||||
if err != nil {
|
||||
|
|
|
@ -5,6 +5,8 @@ import (
|
|||
)
|
||||
|
||||
func TestNewFromYAML(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
yamlData string
|
||||
|
@ -22,9 +24,9 @@ func TestNewFromYAML(t *testing.T) {
|
|||
{
|
||||
name: "empty YAML",
|
||||
yamlData: "",
|
||||
check: nil,
|
||||
expectAnswers: nil,
|
||||
expectErr: true,
|
||||
check: []string{"oak", "walnut", "maple", "cherry"},
|
||||
expectAnswers: []bool{false, false, false, false},
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid YAML",
|
||||
|
@ -37,6 +39,8 @@ func TestNewFromYAML(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
list, err := NewFromYAML[string]([]byte(tt.yamlData))
|
||||
if (err != nil) != tt.expectErr {
|
||||
t.Fatalf("NewFromYAML() error = %v, expectErr = %v", err, tt.expectErr)
|
||||
|
@ -53,3 +57,53 @@ func TestNewFromYAML(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
members []string
|
||||
check []string
|
||||
expectAnswers []bool
|
||||
}{
|
||||
{
|
||||
name: "unique members",
|
||||
members: []string{"oak", "maple", "cherry"},
|
||||
check: []string{"oak", "walnut", "maple", "cherry"},
|
||||
expectAnswers: []bool{true, false, true, true},
|
||||
},
|
||||
{
|
||||
name: "duplicate members",
|
||||
members: []string{"oak", "maple", "cherry", "oak"},
|
||||
check: []string{"oak", "walnut", "maple", "cherry"},
|
||||
expectAnswers: []bool{true, false, true, true},
|
||||
},
|
||||
{
|
||||
name: "nil list",
|
||||
members: nil,
|
||||
check: []string{"oak", "walnut", "maple", "cherry"},
|
||||
expectAnswers: []bool{false, false, false, false},
|
||||
},
|
||||
{
|
||||
name: "empty list",
|
||||
members: []string{},
|
||||
check: []string{"oak", "walnut", "maple", "cherry"},
|
||||
expectAnswers: []bool{false, false, false, false},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
list := NewList[string](tt.members)
|
||||
for i, item := range tt.check {
|
||||
got := list.Contains(item)
|
||||
if got != tt.expectAnswers[i] {
|
||||
t.Errorf("Contains(%q) got %v, want %v", item, got, tt.expectAnswers[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,10 +3,12 @@ package notmain
|
|||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
akamaipb "github.com/letsencrypt/boulder/akamai/proto"
|
||||
"github.com/letsencrypt/boulder/allowlist"
|
||||
capb "github.com/letsencrypt/boulder/ca/proto"
|
||||
"github.com/letsencrypt/boulder/cmd"
|
||||
"github.com/letsencrypt/boulder/config"
|
||||
|
@ -91,6 +93,18 @@ type Config struct {
|
|||
// you need to request a new challenge.
|
||||
PendingAuthorizationLifetimeDays int `validate:"required,min=1,max=29"`
|
||||
|
||||
// ValidationProfiles is a map of validation profiles to their
|
||||
// respective issuance allow lists. If a profile is not included in this
|
||||
// mapping, it cannot be used by any account. If this field is left
|
||||
// empty, all profiles are open to all accounts.
|
||||
ValidationProfiles map[string]struct {
|
||||
// AllowList specifies the path to a YAML file containing a list of
|
||||
// account IDs permitted to use this profile. If no path is
|
||||
// specified, the profile is open to all accounts. If the file
|
||||
// exists but is empty, the profile is closed to all accounts.
|
||||
AllowList string `validate:"omitempty"`
|
||||
}
|
||||
|
||||
// GoodKey is an embedded config stanza for the goodkey library.
|
||||
GoodKey goodkey.Config
|
||||
|
||||
|
@ -252,6 +266,21 @@ func main() {
|
|||
}
|
||||
pendingAuthorizationLifetime := time.Duration(c.RA.PendingAuthorizationLifetimeDays) * 24 * time.Hour
|
||||
|
||||
var validationProfiles map[string]*ra.ValidationProfile
|
||||
if c.RA.ValidationProfiles != nil {
|
||||
validationProfiles = make(map[string]*ra.ValidationProfile)
|
||||
for profileName, v := range c.RA.ValidationProfiles {
|
||||
var allowList *allowlist.List[int64]
|
||||
if v.AllowList != "" {
|
||||
data, err := os.ReadFile(v.AllowList)
|
||||
cmd.FailOnError(err, fmt.Sprintf("Failed to read allow list for profile %q", profileName))
|
||||
allowList, err = allowlist.NewFromYAML[int64](data)
|
||||
cmd.FailOnError(err, fmt.Sprintf("Failed to parse allow list for profile %q", profileName))
|
||||
}
|
||||
validationProfiles[profileName] = ra.NewValidationProfile(allowList)
|
||||
}
|
||||
}
|
||||
|
||||
if features.Get().AsyncFinalize && c.RA.FinalizeTimeout.Duration == 0 {
|
||||
cmd.Fail("finalizeTimeout must be supplied when AsyncFinalize feature is enabled")
|
||||
}
|
||||
|
@ -289,6 +318,7 @@ func main() {
|
|||
c.RA.MaxNames,
|
||||
authorizationLifetime,
|
||||
pendingAuthorizationLifetime,
|
||||
validationProfiles,
|
||||
pubc,
|
||||
c.RA.OrderLifetime.Duration,
|
||||
c.RA.FinalizeTimeout.Duration,
|
||||
|
|
32
ra/ra.go
32
ra/ra.go
|
@ -28,6 +28,7 @@ import (
|
|||
|
||||
"github.com/letsencrypt/boulder/akamai"
|
||||
akamaipb "github.com/letsencrypt/boulder/akamai/proto"
|
||||
"github.com/letsencrypt/boulder/allowlist"
|
||||
capb "github.com/letsencrypt/boulder/ca/proto"
|
||||
"github.com/letsencrypt/boulder/core"
|
||||
corepb "github.com/letsencrypt/boulder/core/proto"
|
||||
|
@ -65,6 +66,19 @@ var (
|
|||
caaRecheckDuration = -7 * time.Hour
|
||||
)
|
||||
|
||||
// ValidationProfile holds the allowlist for a given validation profile.
|
||||
type ValidationProfile struct {
|
||||
// allowList holds the set of account IDs allowed to use this profile. If
|
||||
// nil, the profile is open to all accounts (everyone is allowed).
|
||||
allowList *allowlist.List[int64]
|
||||
}
|
||||
|
||||
// NewValidationProfile creates a new ValidationProfile with the provided
|
||||
// allowList. A nil allowList is interpreted as open access for all accounts.
|
||||
func NewValidationProfile(allowList *allowlist.List[int64]) *ValidationProfile {
|
||||
return &ValidationProfile{allowList: allowList}
|
||||
}
|
||||
|
||||
// RegistrationAuthorityImpl defines an RA.
|
||||
//
|
||||
// NOTE: All of the fields in RegistrationAuthorityImpl need to be
|
||||
|
@ -84,6 +98,7 @@ type RegistrationAuthorityImpl struct {
|
|||
// How long before a newly created authorization expires.
|
||||
authorizationLifetime time.Duration
|
||||
pendingAuthorizationLifetime time.Duration
|
||||
validationProfiles map[string]*ValidationProfile
|
||||
maxContactsPerReg int
|
||||
limiter *ratelimits.Limiter
|
||||
txnBuilder *ratelimits.TransactionBuilder
|
||||
|
@ -124,6 +139,7 @@ func NewRegistrationAuthorityImpl(
|
|||
maxNames int,
|
||||
authorizationLifetime time.Duration,
|
||||
pendingAuthorizationLifetime time.Duration,
|
||||
validationProfiles map[string]*ValidationProfile,
|
||||
pubc pubpb.PublisherClient,
|
||||
orderLifetime time.Duration,
|
||||
finalizeTimeout time.Duration,
|
||||
|
@ -230,6 +246,7 @@ func NewRegistrationAuthorityImpl(
|
|||
log: logger,
|
||||
authorizationLifetime: authorizationLifetime,
|
||||
pendingAuthorizationLifetime: pendingAuthorizationLifetime,
|
||||
validationProfiles: validationProfiles,
|
||||
maxContactsPerReg: maxContactsPerReg,
|
||||
keyPolicy: keyPolicy,
|
||||
limiter: limiter,
|
||||
|
@ -2122,6 +2139,21 @@ func (ra *RegistrationAuthorityImpl) NewOrder(ctx context.Context, req *rapb.New
|
|||
"Order cannot contain more than %d DNS names", ra.maxNames)
|
||||
}
|
||||
|
||||
if req.CertificateProfileName != "" && ra.validationProfiles != nil {
|
||||
vp, ok := ra.validationProfiles[req.CertificateProfileName]
|
||||
if !ok {
|
||||
return nil, berrors.MalformedError("requested certificate profile %q not found",
|
||||
req.CertificateProfileName,
|
||||
)
|
||||
}
|
||||
if vp.allowList != nil && !vp.allowList.Contains(req.RegistrationID) {
|
||||
return nil, berrors.UnauthorizedError("account ID %d is not permitted to use certificate profile %q",
|
||||
req.RegistrationID,
|
||||
req.CertificateProfileName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that our policy allows issuing for each of the names in the order
|
||||
err := ra.PA.WillingToIssue(newOrder.DnsNames)
|
||||
if err != nil {
|
||||
|
|
|
@ -36,6 +36,7 @@ import (
|
|||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
akamaipb "github.com/letsencrypt/boulder/akamai/proto"
|
||||
"github.com/letsencrypt/boulder/allowlist"
|
||||
capb "github.com/letsencrypt/boulder/ca/proto"
|
||||
"github.com/letsencrypt/boulder/config"
|
||||
"github.com/letsencrypt/boulder/core"
|
||||
|
@ -342,6 +343,7 @@ func initAuthorities(t *testing.T) (*DummyValidationAuthority, sapb.StorageAutho
|
|||
1, testKeyPolicy, limiter, txnBuilder, 100,
|
||||
300*24*time.Hour, 7*24*time.Hour,
|
||||
nil,
|
||||
nil,
|
||||
7*24*time.Hour, 5*time.Minute,
|
||||
ctp, nil, nil)
|
||||
ra.SA = sa
|
||||
|
@ -1666,6 +1668,65 @@ func TestNewOrder_AuthzReuse_NoPending(t *testing.T) {
|
|||
test.AssertNotEquals(t, new.V2Authorizations[0], extant.V2Authorizations[0])
|
||||
}
|
||||
|
||||
func TestNewOrder_ProfileSelectionAllowList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, _, ra, _, _, cleanUp := initAuthorities(t)
|
||||
defer cleanUp()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
allowList *allowlist.List[int64]
|
||||
expectErr bool
|
||||
expectErrContains string
|
||||
}{
|
||||
{
|
||||
name: "Allow All Account IDs",
|
||||
allowList: nil,
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "Deny all but account Id 1337",
|
||||
allowList: allowlist.NewList([]int64{1337}),
|
||||
expectErr: true,
|
||||
expectErrContains: "not permitted to use certificate profile",
|
||||
},
|
||||
{
|
||||
name: "Deny all",
|
||||
allowList: allowlist.NewList([]int64{}),
|
||||
expectErr: true,
|
||||
expectErrContains: "not permitted to use certificate profile",
|
||||
},
|
||||
{
|
||||
name: "Allow Registration.Id",
|
||||
allowList: allowlist.NewList([]int64{Registration.Id}),
|
||||
expectErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ra.validationProfiles = map[string]*ValidationProfile{
|
||||
"test": NewValidationProfile(tc.allowList),
|
||||
}
|
||||
|
||||
orderReq := &rapb.NewOrderRequest{
|
||||
RegistrationID: Registration.Id,
|
||||
DnsNames: []string{randomDomain()},
|
||||
CertificateProfileName: "test",
|
||||
}
|
||||
_, err := ra.NewOrder(context.Background(), orderReq)
|
||||
|
||||
if tc.expectErrContains != "" {
|
||||
test.AssertErrorIs(t, err, berrors.Unauthorized)
|
||||
test.AssertContains(t, err.Error(), tc.expectErrContains)
|
||||
} else {
|
||||
test.AssertNotError(t, err, "NewOrder failed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mockSAWithAuthzs has a GetAuthorizations2 method that returns the protobuf
|
||||
// version of its authzs struct member. It also has a fake GetOrderForNames
|
||||
// which always fails, and a fake NewOrderAndAuthzs which always succeeds, to
|
||||
|
|
Loading…
Reference in New Issue