Skip to content

Commit

Permalink
admin: Add pause-identifier and unpause-account subcommands (#7668)
Browse files Browse the repository at this point in the history
Implements tooling in `admin` that allows an operator to
administratively pause account/identifier pairs and unpause
whole accounts. This functionality mirrors the self-service
capabilities of the SFE, so that we can administratively intervene
in the pausing and unpausing process.

The new `pause-identifier` subcommand accepts a single form
of input, specified by the `-batch-file` flag. This expects a CSV
where each row is an accountID, identifierType, identifierValue
triple.

The new `unpause-account` subcommand accepts either a single
account ID with the `-account` flag, or a text file containing a list
of account IDs with the `-batch-file` flag.

Relates to #7406
Fixes #7618
  • Loading branch information
pgporada authored Aug 22, 2024
1 parent 4bf6e2f commit c7a04e8
Show file tree
Hide file tree
Showing 5 changed files with 608 additions and 3 deletions.
8 changes: 5 additions & 3 deletions cmd/admin/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,11 @@ func main() {

// This is the registry of all subcommands that the admin tool can run.
subcommands := map[string]subcommand{
"revoke-cert": &subcommandRevokeCert{},
"block-key": &subcommandBlockKey{},
"update-email": &subcommandUpdateEmail{},
"revoke-cert": &subcommandRevokeCert{},
"block-key": &subcommandBlockKey{},
"update-email": &subcommandUpdateEmail{},
"pause-identifier": &subcommandPauseIdentifier{},
"unpause-account": &subcommandUnpauseAccount{},
}

defaultUsage := flag.Usage
Expand Down
152 changes: 152 additions & 0 deletions cmd/admin/pause_identifier.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package main

import (
"context"
"encoding/csv"
"errors"
"flag"
"fmt"
"io"
"os"
"strconv"

"github.com/letsencrypt/boulder/identifier"
sapb "github.com/letsencrypt/boulder/sa/proto"
)

// subcommandPauseIdentifier encapsulates the "admin pause-identifiers" command.
type subcommandPauseIdentifier struct {
batchFile string
}

var _ subcommand = (*subcommandPauseIdentifier)(nil)

func (p *subcommandPauseIdentifier) Desc() string {
return "Administratively pause an account preventing it from attempting certificate issuance"
}

func (p *subcommandPauseIdentifier) Flags(flag *flag.FlagSet) {
flag.StringVar(&p.batchFile, "batch-file", "", "Path to a CSV file containing (account ID, identifier type, identifier value)")
}

func (p *subcommandPauseIdentifier) Run(ctx context.Context, a *admin) error {
if p.batchFile == "" {
return errors.New("the -batch-file flag is required")
}

identifiers, err := a.readPausedAccountFile(p.batchFile)
if err != nil {
return err
}

_, err = a.pauseIdentifiers(ctx, identifiers)
if err != nil {
return err
}

return nil
}

// pauseIdentifiers allows administratively pausing a set of domain names for an
// account. It returns a slice of PauseIdentifiersResponse or an error.
func (a *admin) pauseIdentifiers(ctx context.Context, incoming []pauseCSVData) ([]*sapb.PauseIdentifiersResponse, error) {
if len(incoming) <= 0 {
return nil, errors.New("cannot pause identifiers because no pauseData was sent")
}

var responses []*sapb.PauseIdentifiersResponse
for _, data := range incoming {
req := sapb.PauseRequest{
RegistrationID: data.accountID,
Identifiers: []*sapb.Identifier{
{
Type: string(data.identifierType),
Value: data.identifierValue,
},
},
}
response, err := a.sac.PauseIdentifiers(ctx, &req)
if err != nil {
return nil, err
}
responses = append(responses, response)
}

return responses, nil
}

// pauseCSVData contains a golang representation of the data loaded in from a
// CSV file for pausing.
type pauseCSVData struct {
accountID int64
identifierType identifier.IdentifierType
identifierValue string
}

// readPausedAccountFile parses the contents of a CSV into a slice of
// `pauseCSVData` objects and returns it or an error. It will skip malformed
// lines and continue processing until either the end of file marker is detected
// or other read error.
func (a *admin) readPausedAccountFile(filePath string) ([]pauseCSVData, error) {
fp, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("opening paused account data file: %w", err)
}
defer fp.Close()

reader := csv.NewReader(fp)

// identifierValue can have 1 or more entries
reader.FieldsPerRecord = -1
reader.TrimLeadingSpace = true

var parsedRecords []pauseCSVData
lineCounter := 0

// Process contents of the CSV file
for {
record, err := reader.Read()
if errors.Is(err, io.EOF) {
break
} else if err != nil {
return nil, err
}

lineCounter++

// We should have strictly 3 fields, note that just commas is considered
// a valid CSV line.
if len(record) != 3 {
a.log.Infof("skipping: malformed line %d, should contain exactly 3 fields\n", lineCounter)
continue
}

recordID := record[0]
accountID, err := strconv.ParseInt(recordID, 10, 64)
if err != nil || accountID == 0 {
a.log.Infof("skipping: malformed accountID entry on line %d\n", lineCounter)
continue
}

// Ensure that an identifier type is present, otherwise skip the line.
if len(record[1]) == 0 {
a.log.Infof("skipping: malformed identifierType entry on line %d\n", lineCounter)
continue
}

if len(record[2]) == 0 {
a.log.Infof("skipping: malformed identifierValue entry on line %d\n", lineCounter)
continue
}

parsedRecord := pauseCSVData{
accountID: accountID,
identifierType: identifier.IdentifierType(record[1]),
identifierValue: record[2],
}
parsedRecords = append(parsedRecords, parsedRecord)
}
a.log.Infof("detected %d valid record(s) from input file\n", len(parsedRecords))

return parsedRecords, nil
}
194 changes: 194 additions & 0 deletions cmd/admin/pause_identifier_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package main

import (
"context"
"errors"
"os"
"path"
"strings"
"testing"

blog "github.com/letsencrypt/boulder/log"
sapb "github.com/letsencrypt/boulder/sa/proto"
"github.com/letsencrypt/boulder/test"
"google.golang.org/grpc"
)

func TestReadingPauseCSV(t *testing.T) {
t.Parallel()

testCases := []struct {
name string
data []string
expectedRecords int
}{
{
name: "No data in file",
data: nil,
},
{
name: "valid",
data: []string{"1,dns,example.com"},
expectedRecords: 1,
},
{
name: "valid with duplicates",
data: []string{"1,dns,example.com", "2,dns,example.org", "1,dns,example.com", "1,dns,example.net", "3,dns,example.gov", "3,dns,example.gov"},
expectedRecords: 6,
},
{
name: "invalid with multiple domains on the same line",
data: []string{"1,dns,example.com,example.net"},
},
{
name: "invalid just commas",
data: []string{",,,"},
},
{
name: "invalid only contains accountID",
data: []string{"1"},
},
{
name: "invalid only contains accountID and identifierType",
data: []string{"1,dns"},
},
{
name: "invalid missing identifierType",
data: []string{"1,,example.com"},
},
{
name: "invalid accountID isnt an int",
data: []string{"blorple"},
},
}

for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
log := blog.NewMock()
a := admin{log: log}

csvFile := path.Join(t.TempDir(), path.Base(t.Name()+".csv"))
err := os.WriteFile(csvFile, []byte(strings.Join(testCase.data, "\n")), os.ModePerm)
test.AssertNotError(t, err, "could not write temporary file")

parsedData, err := a.readPausedAccountFile(csvFile)
test.AssertNotError(t, err, "no error expected, but received one")
test.AssertEquals(t, len(parsedData), testCase.expectedRecords)
})
}
}

// mockSAPaused is a mock which always succeeds. It records the PauseRequest it
// received, and returns the number of identifiers as a
// PauseIdentifiersResponse. It does not maintain state of repaused identifiers.
type mockSAPaused struct {
sapb.StorageAuthorityClient
reqs []*sapb.PauseRequest
}

func (msa *mockSAPaused) PauseIdentifiers(ctx context.Context, in *sapb.PauseRequest, _ ...grpc.CallOption) (*sapb.PauseIdentifiersResponse, error) {
msa.reqs = append(msa.reqs, in)

return &sapb.PauseIdentifiersResponse{Paused: int64(len(in.Identifiers))}, nil
}

// mockSAPausedBroken is a mock which always errors.
type mockSAPausedBroken struct {
sapb.StorageAuthorityClient
}

func (msa *mockSAPausedBroken) PauseIdentifiers(ctx context.Context, in *sapb.PauseRequest, _ ...grpc.CallOption) (*sapb.PauseIdentifiersResponse, error) {
return nil, errors.New("its all jacked up")
}

func TestPauseIdentifiers(t *testing.T) {
t.Parallel()

testCases := []struct {
name string
data []pauseCSVData
saImpl sapb.StorageAuthorityClient
expectErr bool
}{
{
name: "no data",
data: nil,
expectErr: true,
},
{
name: "valid single entry",
data: []pauseCSVData{
{
accountID: 1,
identifierType: "dns",
identifierValue: "example.com",
},
},
},
{
name: "valid single entry but broken SA",
expectErr: true,
saImpl: &mockSAPausedBroken{},
data: []pauseCSVData{
{
accountID: 1,
identifierType: "dns",
identifierValue: "example.com",
},
},
},
{
name: "valid multiple entries with duplicates",
data: []pauseCSVData{
{
accountID: 1,
identifierType: "dns",
identifierValue: "example.com",
},
{
accountID: 1,
identifierType: "dns",
identifierValue: "example.com",
},
{
accountID: 2,
identifierType: "dns",
identifierValue: "example.org",
},
{
accountID: 3,
identifierType: "dns",
identifierValue: "example.net",
},
{
accountID: 3,
identifierType: "dns",
identifierValue: "example.org",
},
},
},
}

for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()

log := blog.NewMock()

// Default to a working mock SA implementation
if testCase.saImpl == nil {
testCase.saImpl = &mockSAPaused{}
}
a := admin{sac: testCase.saImpl, log: log}

responses, err := a.pauseIdentifiers(context.Background(), testCase.data)
if testCase.expectErr {
test.AssertError(t, err, "should have errored, but did not")
} else {
test.AssertNotError(t, err, "should not have errored")
test.AssertEquals(t, len(responses), len(testCase.data))
}
})
}
}
Loading

0 comments on commit c7a04e8

Please sign in to comment.