-
-
Notifications
You must be signed in to change notification settings - Fork 610
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
admin: Add pause-identifier and unpause-account subcommands (#7668)
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
Showing
5 changed files
with
608 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | ||
} | ||
}) | ||
} | ||
} |
Oops, something went wrong.