diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 26a0131d..f47c9756 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,3 +18,4 @@ repos: rev: v8.18.4 hooks: - id: gitleaks + args: [ "--baseline-path", "./gitleaks-report.json" ] diff --git a/Makefile b/Makefile index aa62abae..4dfdaab6 100644 --- a/Makefile +++ b/Makefile @@ -65,6 +65,11 @@ test-api: cat ./docs/example-lpa.json | ./api-test/tester -expectedStatus=201 REQUEST PUT $(URL)/lpas/$(LPA_UID) "`xargs -0`" cat ./docs/certificate-provider-opt-out.json | ./api-test/tester -expectedStatus=201 REQUEST POST $(URL)/lpas/$(LPA_UID)/updates "`xargs -0`" + # attorney opt out + $(eval LPA_UID := "$(shell ./api-test/tester UID)") + cat ./docs/example-lpa.json | ./api-test/tester -expectedStatus=201 REQUEST PUT $(URL)/lpas/$(LPA_UID) "`xargs -0`" + cat ./docs/attorney-opt-out.json | ./api-test/tester -expectedStatus=201 -authorUID=9ac5cb7c-fc75-40c7-8e53-059f36dbbe3d REQUEST POST $(URL)/lpas/$(LPA_UID)/updates "`xargs -0`" + # donor withdraws lpa $(eval LPA_UID := "$(shell ./api-test/tester UID)") cat ./docs/example-lpa.json | ./api-test/tester -expectedStatus=201 REQUEST PUT $(URL)/lpas/$(LPA_UID) "`xargs -0`" diff --git a/api-test/main.go b/api-test/main.go index ac9b5899..de658874 100644 --- a/api-test/main.go +++ b/api-test/main.go @@ -30,6 +30,7 @@ import ( func main() { ctx := context.Background() expectedStatusCode := flag.Int("expectedStatus", 200, "Expected response status code") + authorUID := flag.String("authorUID", "34", "Set the UID of the author in the header") writeBody := flag.Bool("write", false, "Write the response body to STDOUT") flag.Parse() args := flag.Args() @@ -41,7 +42,7 @@ func main() { fmt.Print("M-" + strings.ToUpper(uuid.NewString()[9:23])) os.Exit(0) case "JWT": - fmt.Print(makeJwt([]byte(jwtSecret))) + fmt.Print(makeJwt([]byte(jwtSecret), authorUID)) os.Exit(0) case "REQUEST": // continue @@ -68,7 +69,7 @@ func main() { } if jwtSecret != "" { - tokenString := makeJwt([]byte(jwtSecret)) + tokenString := makeJwt([]byte(jwtSecret), authorUID) req.Header.Add("X-Jwt-Authorization", fmt.Sprintf("Bearer %s", tokenString)) } @@ -127,12 +128,12 @@ func main() { } } -func makeJwt(secretKey []byte) string { +func makeJwt(secretKey []byte, uid *string) string { claims := jwt.MapClaims{ "exp": time.Now().Add(time.Hour * 24).Unix(), "iat": time.Now().Add(time.Hour * -24).Unix(), "iss": "opg.poas.sirius", - "sub": "urn:opg:sirius:users:34", + "sub": "urn:opg:poas:sirius:users:" + *uid, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) diff --git a/docs/attorney-opt-out.json b/docs/attorney-opt-out.json new file mode 100644 index 00000000..daa64cdb --- /dev/null +++ b/docs/attorney-opt-out.json @@ -0,0 +1,4 @@ +{ + "type": "ATTORNEY_OPT_OUT", + "changes": [] +} diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index 574aa556..0c926f8a 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -272,6 +272,7 @@ components: - DONOR_CONFIRM_IDENTITY - CERTIFICATE_PROVIDER_CONFIRM_IDENTITY - DONOR_WITHDRAW_LPA + - ATTORNEY_OPT_OUT changes: type: array items: diff --git a/gitleaks-report.json b/gitleaks-report.json new file mode 100644 index 00000000..a0dab391 --- /dev/null +++ b/gitleaks-report.json @@ -0,0 +1,22 @@ +[ + { + "Description": "Detected a Generic API Key, potentially exposing access to various services and sensitive operations.", + "StartLine": 71, + "EndLine": 71, + "StartColumn": 77, + "EndColumn": 123, + "Match": "authorUID=9ac5cb7c-fc75-40c7-8e53-059f36dbbe3d ", + "Secret": "9ac5cb7c-fc75-40c7-8e53-059f36dbbe3d", + "File": "Makefile", + "SymlinkFile": "", + "Commit": "4155b1eec33bdadc524a0582b4b858599f624466", + "Entropy": 3.7289722, + "Author": "Alex Saunders", + "Email": "acsauk@gmail.com", + "Date": "2024-08-08T15:50:22Z", + "Message": "add api test for attorney opt out", + "Tags": [], + "RuleID": "generic-api-key", + "Fingerprint": "4155b1eec33bdadc524a0582b4b858599f624466:Makefile:generic-api-key:71" + } +] diff --git a/internal/shared/lpa.go b/internal/shared/lpa.go index 0fa3a6fa..932d0889 100644 --- a/internal/shared/lpa.go +++ b/internal/shared/lpa.go @@ -1,6 +1,7 @@ package shared import ( + "slices" "time" ) @@ -26,6 +27,44 @@ type LpaInit struct { CertificateProviderNotRelatedConfirmedAt *time.Time `json:"certificateProviderNotRelatedConfirmedAt,omitempty"` } +func (l *Lpa) GetAttorney(uid string) (Attorney, bool) { + idx := slices.IndexFunc(l.Attorneys, func(a Attorney) bool { return a.UID == uid }) + if idx == -1 { + return Attorney{}, false + } + + return l.Attorneys[idx], true +} + +func (l *Lpa) PutAttorney(attorney Attorney) { + idx := slices.IndexFunc(l.Attorneys, func(a Attorney) bool { return a.UID == attorney.UID }) + if idx == -1 { + l.Attorneys = append(l.Attorneys, attorney) + } else { + l.Attorneys[idx] = attorney + } +} + +func (l *Lpa) ActiveAttorneys() (attorneys []Attorney) { + for _, a := range l.Attorneys { + if a.Status == AttorneyStatusActive { + attorneys = append(attorneys, a) + } + } + + return attorneys +} + +func (l *Lpa) ActiveTrustCorporations() (trustCorporations []TrustCorporation) { + for _, tc := range l.TrustCorporations { + if tc.Status == AttorneyStatusActive { + trustCorporations = append(trustCorporations, tc) + } + } + + return trustCorporations +} + type Lpa struct { LpaInit Uid string `json:"uid"` diff --git a/internal/shared/lpa_test.go b/internal/shared/lpa_test.go index 4cb2e1a2..996ad995 100644 --- a/internal/shared/lpa_test.go +++ b/internal/shared/lpa_test.go @@ -19,3 +19,114 @@ func TestLpaInitMarshalJSON(t *testing.T) { data, _ := json.Marshal(LpaInit{}) assert.JSONEq(t, expected, string(data)) } + +func TestAttorneysGet(t *testing.T) { + testCases := map[string]struct { + attorneys []Attorney + expectedAttorney Attorney + uid string + expectedFound bool + }{ + "found": { + attorneys: []Attorney{ + {Person: Person{UID: "abc", FirstNames: "a"}}, + {Person: Person{UID: "xyz", FirstNames: "b"}}, + }, + expectedAttorney: Attorney{Person: Person{UID: "xyz", FirstNames: "b"}}, + uid: "xyz", + expectedFound: true, + }, + "not found": { + attorneys: []Attorney{ + {Person: Person{UID: "abc", FirstNames: "a"}}, + {Person: Person{UID: "xyz", FirstNames: "b"}}, + }, + expectedAttorney: Attorney{}, + uid: "not-a-match", + expectedFound: false, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + lpa := &Lpa{LpaInit: LpaInit{Attorneys: tc.attorneys}} + a, found := lpa.GetAttorney(tc.uid) + + assert.Equal(t, tc.expectedFound, found) + assert.Equal(t, tc.expectedAttorney, a) + }) + } +} + +func TestAttorneysPut(t *testing.T) { + testCases := map[string]struct { + attorneys []Attorney + expectedAttorneys []Attorney + updatedAttorney Attorney + }{ + "does not exist": { + attorneys: []Attorney{ + {Person: Person{UID: "abc", FirstNames: "a"}}, + }, + expectedAttorneys: []Attorney{ + {Person: Person{UID: "abc", FirstNames: "a"}}, + {Person: Person{UID: "xyz", FirstNames: "b"}}, + }, + updatedAttorney: Attorney{Person: Person{UID: "xyz", FirstNames: "b"}}, + }, + "exists": { + attorneys: []Attorney{ + {Person: Person{UID: "abc", FirstNames: "a"}}, + {Person: Person{UID: "xyz", FirstNames: "b"}}, + }, + expectedAttorneys: []Attorney{ + {Person: Person{UID: "abc", FirstNames: "a"}}, + {Person: Person{UID: "xyz", FirstNames: "z"}}, + }, + updatedAttorney: Attorney{Person: Person{UID: "xyz", FirstNames: "z"}}, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + lpa := &Lpa{LpaInit: LpaInit{Attorneys: tc.attorneys}} + lpa.PutAttorney(tc.updatedAttorney) + + assert.Equal(t, tc.expectedAttorneys, lpa.Attorneys) + }) + } +} + +func TestActiveAttorneys(t *testing.T) { + lpa := &Lpa{LpaInit: LpaInit{ + Attorneys: []Attorney{ + {Person: Person{FirstNames: "a"}}, + {Person: Person{FirstNames: "b"}, Status: AttorneyStatusActive}, + {Person: Person{FirstNames: "c"}, Status: AttorneyStatusReplacement}, + {Person: Person{FirstNames: "d"}, Status: AttorneyStatusRemoved}, + {Person: Person{FirstNames: "e"}, Status: AttorneyStatusActive}, + }, + }} + + assert.Equal(t, []Attorney{ + {Person: Person{FirstNames: "b"}, Status: AttorneyStatusActive}, + {Person: Person{FirstNames: "e"}, Status: AttorneyStatusActive}, + }, lpa.ActiveAttorneys()) +} + +func TestActiveTrustCorporations(t *testing.T) { + lpa := &Lpa{LpaInit: LpaInit{ + TrustCorporations: []TrustCorporation{ + {Name: "a"}, + {Name: "b", Status: AttorneyStatusActive}, + {Name: "c", Status: AttorneyStatusReplacement}, + {Name: "d", Status: AttorneyStatusRemoved}, + {Name: "e", Status: AttorneyStatusActive}, + }, + }} + + assert.Equal(t, []TrustCorporation{ + {Name: "b", Status: AttorneyStatusActive}, + {Name: "e", Status: AttorneyStatusActive}, + }, lpa.ActiveTrustCorporations()) +} diff --git a/internal/shared/update.go b/internal/shared/update.go index 2eefd4d8..d319eced 100644 --- a/internal/shared/update.go +++ b/internal/shared/update.go @@ -1,6 +1,9 @@ package shared -import "encoding/json" +import ( + "encoding/json" + "strings" +) type Change struct { Key string `json:"key"` @@ -8,11 +11,31 @@ type Change struct { New json.RawMessage `json:"new"` } +type URN string + +func (u URN) Details() AuthorDetails { + parts := strings.Split(string(u), ":") + + if len(parts) != 6 || parts[3] == "" || parts[5] == "" { + return AuthorDetails{} + } + + return AuthorDetails{ + UID: parts[5], + Service: parts[3], + } +} + type Update struct { Id string `json:"id"` // UUID for the update Uid string `json:"uid"` // UID of the changed LPA Applied string `json:"applied"` // RFC3339 datetime - Author string `json:"author"` + Author URN `json:"author"` Type string `json:"type"` Changes []Change `json:"changes"` } + +type AuthorDetails struct { + UID string + Service string +} diff --git a/internal/shared/update_test.go b/internal/shared/update_test.go new file mode 100644 index 00000000..c4f34f0d --- /dev/null +++ b/internal/shared/update_test.go @@ -0,0 +1,48 @@ +package shared + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestURNDetails(t *testing.T) { + testcases := map[URN]struct { + UID string + Service string + }{ + "urn:opg:poas:makeregister:users:123": { + UID: "123", + Service: "makeregister", + }, + "urn:opg:poas:sirius:users:456": { + UID: "456", + Service: "sirius", + }, + } + + for urn, tc := range testcases { + t.Run(string(urn), func(t *testing.T) { + details := urn.Details() + + assert.Equal(t, tc.UID, details.UID) + assert.Equal(t, tc.Service, details.Service) + }) + } + +} + +func TestURNDetailsWhenURNInvalidFormat(t *testing.T) { + testcases := []URN{ + "urn:opg:poas:makeregister:users:", + "urn-opg-poas-makeregister-users-123", + } + + for _, urn := range testcases { + t.Run(string(urn), func(t *testing.T) { + details := urn.Details() + + assert.Equal(t, AuthorDetails{}, details) + }) + } +} diff --git a/lambda/update/attorney_opt_out.go b/lambda/update/attorney_opt_out.go new file mode 100644 index 00000000..3c01e33e --- /dev/null +++ b/lambda/update/attorney_opt_out.go @@ -0,0 +1,48 @@ +package main + +import ( + "github.com/ministryofjustice/opg-data-lpa-store/internal/shared" + "github.com/ministryofjustice/opg-data-lpa-store/internal/validate" +) + +type AttorneyOptOut struct { + AttorneyUID string +} + +func (c AttorneyOptOut) Apply(lpa *shared.Lpa) []shared.FieldError { + attorney, ok := lpa.GetAttorney(c.AttorneyUID) + if !ok { + return []shared.FieldError{{Source: "/type", Detail: "attorney not found"}} + } + + if attorney.SignedAt != nil && !attorney.SignedAt.IsZero() { + return []shared.FieldError{{Source: "/type", Detail: "attorney cannot opt out after signing"}} + } + + attorney.Status = shared.AttorneyStatusRemoved + lpa.PutAttorney(attorney) + + attorneysCount := len(lpa.ActiveAttorneys()) + len(lpa.ActiveTrustCorporations()) + + if attorneysCount == 0 { + lpa.Status = shared.LpaStatusCannotRegister + } else if lpa.HowAttorneysMakeDecisions == shared.HowMakeDecisionsJointly || lpa.HowAttorneysMakeDecisions == shared.HowMakeDecisionsJointlyForSomeSeverallyForOthers { + lpa.Status = shared.LpaStatusCannotRegister + } + + return nil +} + +func validateAttorneyOptOut(update shared.Update) (AttorneyOptOut, []shared.FieldError) { + if len(update.Changes) > 0 { + return AttorneyOptOut{}, []shared.FieldError{{Source: "/changes", Detail: "expected empty"}} + } + + author := update.Author.Details() + + if errs := validate.UUID("/author", author.UID); len(errs) > 0 { + return AttorneyOptOut{}, errs + } + + return AttorneyOptOut{AttorneyUID: author.UID}, nil +} diff --git a/lambda/update/attorney_opt_out_test.go b/lambda/update/attorney_opt_out_test.go new file mode 100644 index 00000000..42a760be --- /dev/null +++ b/lambda/update/attorney_opt_out_test.go @@ -0,0 +1,201 @@ +package main + +import ( + "encoding/json" + "testing" + + "github.com/ministryofjustice/opg-data-lpa-store/internal/shared" + "github.com/stretchr/testify/assert" +) + +func TestAttorneyOptOutApply(t *testing.T) { + testcases := map[string]struct { + lpa *shared.Lpa + expectedLpa *shared.Lpa + }{ + "single attorney": { + lpa: &shared.Lpa{ + Status: shared.LpaStatusInProgress, + LpaInit: shared.LpaInit{ + HowAttorneysMakeDecisions: shared.HowMakeDecisionsUnset, + Attorneys: []shared.Attorney{ + {Person: shared.Person{UID: "b"}, Status: shared.AttorneyStatusActive}, + }, + }, + }, + expectedLpa: &shared.Lpa{ + Status: shared.LpaStatusCannotRegister, + LpaInit: shared.LpaInit{ + HowAttorneysMakeDecisions: shared.HowMakeDecisionsUnset, + Attorneys: []shared.Attorney{ + {Person: shared.Person{UID: "b"}, Status: shared.AttorneyStatusRemoved}, + }, + }, + }, + }, + "multiple attorneys jointly and severally": { + lpa: &shared.Lpa{ + Status: shared.LpaStatusInProgress, + LpaInit: shared.LpaInit{ + HowAttorneysMakeDecisions: shared.HowMakeDecisionsJointlyAndSeverally, + Attorneys: []shared.Attorney{ + {Person: shared.Person{UID: "a"}, Status: shared.AttorneyStatusActive}, + {Person: shared.Person{UID: "b"}, Status: shared.AttorneyStatusActive}, + {Person: shared.Person{UID: "c"}, Status: shared.AttorneyStatusActive}, + }, + }, + }, + expectedLpa: &shared.Lpa{ + Status: shared.LpaStatusInProgress, + LpaInit: shared.LpaInit{ + HowAttorneysMakeDecisions: shared.HowMakeDecisionsJointlyAndSeverally, + Attorneys: []shared.Attorney{ + {Person: shared.Person{UID: "a"}, Status: shared.AttorneyStatusActive}, + {Person: shared.Person{UID: "b"}, Status: shared.AttorneyStatusRemoved}, + {Person: shared.Person{UID: "c"}, Status: shared.AttorneyStatusActive}, + }, + }, + }, + }, + "multiple attorneys jointly": { + lpa: &shared.Lpa{ + Status: shared.LpaStatusInProgress, + LpaInit: shared.LpaInit{ + HowAttorneysMakeDecisions: shared.HowMakeDecisionsJointly, + Attorneys: []shared.Attorney{ + {Person: shared.Person{UID: "a"}, Status: shared.AttorneyStatusActive}, + {Person: shared.Person{UID: "b"}, Status: shared.AttorneyStatusActive}, + {Person: shared.Person{UID: "c"}, Status: shared.AttorneyStatusActive}, + }, + }, + }, + expectedLpa: &shared.Lpa{ + Status: shared.LpaStatusCannotRegister, + LpaInit: shared.LpaInit{ + HowAttorneysMakeDecisions: shared.HowMakeDecisionsJointly, + Attorneys: []shared.Attorney{ + {Person: shared.Person{UID: "a"}, Status: shared.AttorneyStatusActive}, + {Person: shared.Person{UID: "b"}, Status: shared.AttorneyStatusRemoved}, + {Person: shared.Person{UID: "c"}, Status: shared.AttorneyStatusActive}, + }, + }, + }, + }, + "multiple attorneys jointly for some": { + lpa: &shared.Lpa{ + Status: shared.LpaStatusInProgress, + LpaInit: shared.LpaInit{ + HowAttorneysMakeDecisions: shared.HowMakeDecisionsJointlyForSomeSeverallyForOthers, + Attorneys: []shared.Attorney{ + {Person: shared.Person{UID: "a"}, Status: shared.AttorneyStatusActive}, + {Person: shared.Person{UID: "b"}, Status: shared.AttorneyStatusActive}, + {Person: shared.Person{UID: "c"}, Status: shared.AttorneyStatusActive}, + }, + }, + }, + expectedLpa: &shared.Lpa{ + Status: shared.LpaStatusCannotRegister, + LpaInit: shared.LpaInit{ + HowAttorneysMakeDecisions: shared.HowMakeDecisionsJointlyForSomeSeverallyForOthers, + Attorneys: []shared.Attorney{ + {Person: shared.Person{UID: "a"}, Status: shared.AttorneyStatusActive}, + {Person: shared.Person{UID: "b"}, Status: shared.AttorneyStatusRemoved}, + {Person: shared.Person{UID: "c"}, Status: shared.AttorneyStatusActive}, + }, + }, + }, + }, + "multiple attorneys with trust corporations": { + lpa: &shared.Lpa{ + Status: shared.LpaStatusInProgress, + LpaInit: shared.LpaInit{ + HowAttorneysMakeDecisions: shared.HowMakeDecisionsJointlyAndSeverally, + Attorneys: []shared.Attorney{ + {Person: shared.Person{UID: "a"}, Status: shared.AttorneyStatusActive}, + {Person: shared.Person{UID: "b"}, Status: shared.AttorneyStatusActive}, + }, + TrustCorporations: []shared.TrustCorporation{ + {Status: shared.AttorneyStatusActive}, + }, + }, + }, + expectedLpa: &shared.Lpa{ + Status: shared.LpaStatusInProgress, + LpaInit: shared.LpaInit{ + HowAttorneysMakeDecisions: shared.HowMakeDecisionsJointlyAndSeverally, + Attorneys: []shared.Attorney{ + {Person: shared.Person{UID: "a"}, Status: shared.AttorneyStatusActive}, + {Person: shared.Person{UID: "b"}, Status: shared.AttorneyStatusRemoved}, + }, + TrustCorporations: []shared.TrustCorporation{ + {Status: shared.AttorneyStatusActive}, + }, + }, + }, + }, + } + + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + c := AttorneyOptOut{AttorneyUID: "b"} + + errors := c.Apply(tc.lpa) + + assert.Empty(t, errors) + assert.Equal(t, tc.expectedLpa, tc.lpa) + }) + } +} + +func TestValidateUpdateAttorneyOptOut(t *testing.T) { + testcases := map[string]struct { + update shared.Update + errors []shared.FieldError + expected AttorneyOptOut + }{ + "valid": { + update: shared.Update{ + Author: "urn:opg:poas:makeregister:users:dc487ebb-b39d-45ed-bb6a-7f950fd355c9", + Type: "ATTORNEY_OPT_OUT", + Changes: []shared.Change{}, + }, + expected: AttorneyOptOut{AttorneyUID: "dc487ebb-b39d-45ed-bb6a-7f950fd355c9"}, + }, + "with changes": { + update: shared.Update{ + Author: "urn:opg:poas:makeregister:users:dc487ebb-b39d-45ed-bb6a-7f950fd355c9", + Type: "ATTORNEY_OPT_OUT", + Changes: []shared.Change{ + { + Key: "/something/someValue", + New: json.RawMessage(`"not expected"`), + Old: jsonNull, + }, + }, + }, + expected: AttorneyOptOut{}, + errors: []shared.FieldError{ + {Source: "/changes", Detail: "expected empty"}, + }, + }, + "author UID not valid": { + update: shared.Update{ + Author: "urn:opg:poas:makeregister:users:not-a-uid", + Type: "ATTORNEY_OPT_OUT", + Changes: []shared.Change{}, + }, + expected: AttorneyOptOut{}, + errors: []shared.FieldError{ + {Source: "/author", Detail: "invalid format"}, + }, + }, + } + + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + data, errors := validateUpdate(tc.update, &shared.Lpa{}) + assert.Equal(t, tc.expected, data) + assert.ElementsMatch(t, tc.errors, errors) + }) + } +} diff --git a/lambda/update/main.go b/lambda/update/main.go index 0a5b5d36..975c905c 100644 --- a/lambda/update/main.go +++ b/lambda/update/main.go @@ -74,6 +74,9 @@ func (l *Lambda) HandleEvent(ctx context.Context, req events.APIGatewayProxyRequ return shared.ProblemNotFoundRequest.Respond() } + subject, _ := claims.GetSubject() + update.Author = shared.URN(subject) + applyable, errors := validateUpdate(update, &lpa) if len(errors) > 0 { problem := shared.ProblemInvalidRequest @@ -92,7 +95,6 @@ func (l *Lambda) HandleEvent(ctx context.Context, req events.APIGatewayProxyRequ update.Id = uuid.NewString() update.Uid = lpa.Uid update.Applied = time.Now().UTC().Format(time.RFC3339) - update.Author, _ = claims.GetSubject() if err := l.store.PutChanges(ctx, lpa, update); err != nil { l.logger.Error("error saving changes", slog.Any("err", err)) diff --git a/lambda/update/validate.go b/lambda/update/validate.go index 2f6efb1f..5a710c9f 100644 --- a/lambda/update/validate.go +++ b/lambda/update/validate.go @@ -28,6 +28,8 @@ func validateUpdate(update shared.Update, lpa *shared.Lpa) (Applyable, []shared. return validateCertificateProviderConfirmIdentity(update.Changes, lpa) case "DONOR_WITHDRAW_LPA": return validateDonorWithdrawLPA(update.Changes) + case "ATTORNEY_OPT_OUT": + return validateAttorneyOptOut(update) default: return nil, []shared.FieldError{{Source: "/type", Detail: "invalid value"}} }