Repository: moov-io/customers
Pull request #51 (2019-11-05 17:59:23)

cmd/server: add endpoint for manual OFAC refresh
Adding an endpoint to search OFAC for a given customer at any given time. 

Issue: https://github.com/moov-io/paygate/issues/326


Patch:
diff --git a/client/README.md b/client/README.md
index 21fff2b..160dba7 100644
--- a/client/README.md
+++ b/client/README.md
@@ -41,6 +41,7 @@ Class | Method | HTTP request | Description
 *CustomersApi* | [**GetCustomerDocumentContents**](docs/CustomersApi.md#getcustomerdocumentcontents) | **Get** /customers/{customerID}/documents/{documentID} | Retrieve the referenced document
 *CustomersApi* | [**GetCustomerDocuments**](docs/CustomersApi.md#getcustomerdocuments) | **Get** /customers/{customerID}/documents | Get documents for a customer
 *CustomersApi* | [**Ping**](docs/CustomersApi.md#ping) | **Get** /ping | Ping the Customers service to check if running
+*CustomersApi* | [**RefreshOFACSearch**](docs/CustomersApi.md#refreshofacsearch) | **Put** /customers/{customerID}/refresh/ofac | Refresh OFAC search for a given Customer
 *CustomersApi* | [**ReplaceCustomerMetadata**](docs/CustomersApi.md#replacecustomermetadata) | **Put** /customers/{customerID}/metadata | Replace the metadata object for a customer. Metadata is a map of unique keys associated to values to act as foreign key relationships or arbitrary data associated to a Customer.
 *CustomersApi* | [**UpdateCustomerStatus**](docs/CustomersApi.md#updatecustomerstatus) | **Put** /customers/{customerID}/status | Update the status for a customer, which can only be updated by authenticated users with permissions.
 *CustomersApi* | [**UploadCustomerDocument**](docs/CustomersApi.md#uploadcustomerdocument) | **Post** /customers/{customerID}/documents | Upload a document for the given customer.
diff --git a/client/api/openapi.yaml b/client/api/openapi.yaml
index 627314c..3e0a551 100644
--- a/client/api/openapi.yaml
+++ b/client/api/openapi.yaml
@@ -549,6 +549,54 @@ paths:
       summary: Retrieve the referenced document
       tags:
       - Customers
+  /customers/{customerID}/refresh/ofac:
+    put:
+      operationId: refreshOFACSearch
+      parameters:
+      - description: Optional Request ID allows application developer to trace requests
+          through the systems logs
+        example: rs4f9915
+        explode: false
+        in: header
+        name: X-Request-ID
+        required: false
+        schema:
+          type: string
+        style: simple
+      - description: Moov User ID
+        example: e3cdf999
+        explode: false
+        in: header
+        name: X-User-ID
+        required: false
+        schema:
+          type: string
+        style: simple
+      - description: Customer ID
+        explode: false
+        in: path
+        name: customerID
+        required: true
+        schema:
+          example: e210a9d6
+          type: string
+        style: simple
+      responses:
+        200:
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Customer'
+          description: OFAC search did not block customer
+        400:
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+          description: An error occurred when searching OFAC data
+      summary: Refresh OFAC search for a given Customer
+      tags:
+      - Customers
 components:
   schemas:
     Error:
diff --git a/client/api_customers.go b/client/api_customers.go
index eb03186..c206f95 100644
--- a/client/api_customers.go
+++ b/client/api_customers.go
@@ -848,6 +848,117 @@ func (a *CustomersApiService) Ping(ctx _context.Context) (*_nethttp.Response, er
 	return localVarHTTPResponse, nil
 }
 
+// RefreshOFACSearchOpts Optional parameters for the method 'RefreshOFACSearch'
+type RefreshOFACSearchOpts struct {
+	XRequestID optional.String
+	XUserID    optional.String
+}
+
+/*
+RefreshOFACSearch Refresh OFAC search for a given Customer
+ * @param ctx _context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
+ * @param customerID Customer ID
+ * @param optional nil or *RefreshOFACSearchOpts - Optional Parameters:
+ * @param "XRequestID" (optional.String) -  Optional Request ID allows application developer to trace requests through the systems logs
+ * @param "XUserID" (optional.String) -  Moov User ID
+@return Customer
+*/
+func (a *CustomersApiService) RefreshOFACSearch(ctx _context.Context, customerID string, localVarOptionals *RefreshOFACSearchOpts) (Customer, *_nethttp.Response, error) {
+	var (
+		localVarHTTPMethod   = _nethttp.MethodPut
+		localVarPostBody     interface{}
+		localVarFormFileName string
+		localVarFileName     string
+		localVarFileBytes    []byte
+		localVarReturnValue  Customer
+	)
+
+	// create path and map variables
+	localVarPath := a.client.cfg.BasePath + "/customers/{customerID}/refresh/ofac"
+	localVarPath = strings.Replace(localVarPath, "{"+"customerID"+"}", _neturl.QueryEscape(fmt.Sprintf("%v", customerID)), -1)
+
+	localVarHeaderParams := make(map[string]string)
+	localVarQueryParams := _neturl.Values{}
+	localVarFormParams := _neturl.Values{}
+
+	// to determine the Content-Type header
+	localVarHTTPContentTypes := []string{}
+
+	// set Content-Type header
+	localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
+	if localVarHTTPContentType != "" {
+		localVarHeaderParams["Content-Type"] = localVarHTTPContentType
+	}
+
+	// to determine the Accept header
+	localVarHTTPHeaderAccepts := []string{"application/json"}
+
+	// set Accept header
+	localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
+	if localVarHTTPHeaderAccept != "" {
+		localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
+	}
+	if localVarOptionals != nil && localVarOptionals.XRequestID.IsSet() {
+		localVarHeaderParams["X-Request-ID"] = parameterToString(localVarOptionals.XRequestID.Value(), "")
+	}
+	if localVarOptionals != nil && localVarOptionals.XUserID.IsSet() {
+		localVarHeaderParams["X-User-ID"] = parameterToString(localVarOptionals.XUserID.Value(), "")
+	}
+	r, err := a.client.prepareRequest(ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, localVarFormFileName, localVarFileName, localVarFileBytes)
+	if err != nil {
+		return localVarReturnValue, nil, err
+	}
+
+	localVarHTTPResponse, err := a.client.callAPI(r)
+	if err != nil || localVarHTTPResponse == nil {
+		return localVarReturnValue, localVarHTTPResponse, err
+	}
+
+	localVarBody, err := _ioutil.ReadAll(localVarHTTPResponse.Body)
+	localVarHTTPResponse.Body.Close()
+	if err != nil {
+		return localVarReturnValue, localVarHTTPResponse, err
+	}
+
+	if localVarHTTPResponse.StatusCode >= 300 {
+		newErr := GenericOpenAPIError{
+			body:  localVarBody,
+			error: localVarHTTPResponse.Status,
+		}
+		if localVarHTTPResponse.StatusCode == 200 {
+			var v Customer
+			err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
+			if err != nil {
+				newErr.error = err.Error()
+				return localVarReturnValue, localVarHTTPResponse, newErr
+			}
+			newErr.model = v
+			return localVarReturnValue, localVarHTTPResponse, newErr
+		}
+		if localVarHTTPResponse.StatusCode == 400 {
+			var v Error
+			err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
+			if err != nil {
+				newErr.error = err.Error()
+				return localVarReturnValue, localVarHTTPResponse, newErr
+			}
+			newErr.model = v
+		}
+		return localVarReturnValue, localVarHTTPResponse, newErr
+	}
+
+	err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
+	if err != nil {
+		newErr := GenericOpenAPIError{
+			body:  localVarBody,
+			error: err.Error(),
+		}
+		return localVarReturnValue, localVarHTTPResponse, newErr
+	}
+
+	return localVarReturnValue, localVarHTTPResponse, nil
+}
+
 // ReplaceCustomerMetadataOpts Optional parameters for the method 'ReplaceCustomerMetadata'
 type ReplaceCustomerMetadataOpts struct {
 	XRequestID optional.String
diff --git a/client/docs/CustomersApi.md b/client/docs/CustomersApi.md
index 1954a2f..03f8b31 100644
--- a/client/docs/CustomersApi.md
+++ b/client/docs/CustomersApi.md
@@ -12,6 +12,7 @@ Method | HTTP request | Description
 [**GetCustomerDocumentContents**](CustomersApi.md#GetCustomerDocumentContents) | **Get** /customers/{customerID}/documents/{documentID} | Retrieve the referenced document
 [**GetCustomerDocuments**](CustomersApi.md#GetCustomerDocuments) | **Get** /customers/{customerID}/documents | Get documents for a customer
 [**Ping**](CustomersApi.md#Ping) | **Get** /ping | Ping the Customers service to check if running
+[**RefreshOFACSearch**](CustomersApi.md#RefreshOFACSearch) | **Put** /customers/{customerID}/refresh/ofac | Refresh OFAC search for a given Customer
 [**ReplaceCustomerMetadata**](CustomersApi.md#ReplaceCustomerMetadata) | **Put** /customers/{customerID}/metadata | Replace the metadata object for a customer. Metadata is a map of unique keys associated to values to act as foreign key relationships or arbitrary data associated to a Customer.
 [**UpdateCustomerStatus**](CustomersApi.md#UpdateCustomerStatus) | **Put** /customers/{customerID}/status | Update the status for a customer, which can only be updated by authenticated users with permissions.
 [**UploadCustomerDocument**](CustomersApi.md#UploadCustomerDocument) | **Post** /customers/{customerID}/documents | Upload a document for the given customer.
@@ -360,6 +361,50 @@ No authorization required
 [[Back to README]](../README.md)
 
 
+## RefreshOFACSearch
+
+> Customer RefreshOFACSearch(ctx, customerID, optional)
+
+Refresh OFAC search for a given Customer
+
+### Required Parameters
+
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+**ctx** | **context.Context** | context for authentication, logging, cancellation, deadlines, tracing, etc.
+**customerID** | **string**| Customer ID | 
+ **optional** | ***RefreshOFACSearchOpts** | optional parameters | nil if no parameters
+
+### Optional Parameters
+
+Optional parameters are passed through a pointer to a RefreshOFACSearchOpts struct
+
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+
+ **xRequestID** | **optional.String**| Optional Request ID allows application developer to trace requests through the systems logs | 
+ **xUserID** | **optional.String**| Moov User ID | 
+
+### Return type
+
+[**Customer**](Customer.md)
+
+### Authorization
+
+No authorization required
+
+### HTTP request headers
+
+- **Content-Type**: Not defined
+- **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints)
+[[Back to Model list]](../README.md#documentation-for-models)
+[[Back to README]](../README.md)
+
+
 ## ReplaceCustomerMetadata
 
 > Customer ReplaceCustomerMetadata(ctx, customerID, customerMetadata, optional)
diff --git a/cmd/server/approval_ofac.go b/cmd/server/approval_ofac.go
index 3e34b8d..4545b86 100644
--- a/cmd/server/approval_ofac.go
+++ b/cmd/server/approval_ofac.go
@@ -6,11 +6,19 @@ package main
 
 import (
 	"context"
+	"encoding/json"
+	"errors"
 	"fmt"
+	"net/http"
 	"time"
 
+	moovhttp "github.com/moov-io/base/http"
+	"github.com/moov-io/customers"
 	client "github.com/moov-io/customers/client"
 	ofac "github.com/moov-io/ofac/client"
+
+	"github.com/go-kit/kit/log"
+	"github.com/gorilla/mux"
 )
 
 type ofacSearchResult struct {
@@ -31,6 +39,10 @@ func (s *ofacSearcher) storeCustomerOFACSearch(cust *client.Customer, requestID
 	ctx, cancelFn := context.WithTimeout(context.TODO(), 10*time.Second)
 	defer cancelFn()
 
+	if cust == nil {
+		return errors.New("nil Customer")
+	}
+
 	sdn, err := s.ofacClient.Search(ctx, formatCustomerName(cust), requestID)
 	if err != nil {
 		return fmt.Errorf("ofacSearcher.storeCustomerOFACSearch: name search for customer=%s: %v", cust.ID, err)
@@ -64,3 +76,54 @@ func (s *ofacSearcher) storeCustomerOFACSearch(cust *client.Customer, requestID
 	}
 	return nil
 }
+
+func addOFACRoutes(logger log.Logger, r *mux.Router, repo customerRepository, ofac *ofacSearcher) {
+	r.Methods("PUT").Path("/customers/{customerID}/refresh/ofac").HandlerFunc(refreshOFACSearch(logger, repo, ofac))
+}
+
+func refreshOFACSearch(logger log.Logger, repo customerRepository, ofac *ofacSearcher) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		w = wrapResponseWriter(logger, w, r)
+		w.Header().Set("Content-Type", "application/json; charset=utf-8")
+
+		requestID, userID := moovhttp.GetRequestID(r), moovhttp.GetUserID(r)
+		customerID := getCustomerID(w, r)
+		if customerID == "" {
+			return
+		}
+
+		cust, err := repo.getCustomer(customerID)
+		if err != nil {
+			moovhttp.Problem(w, err)
+			return
+		}
+
+		logger.Log("ofac", fmt.Sprintf("running live OFAC search for customer=%s", customerID), "requestID", requestID, "userID", userID)
+
+		if err := ofac.storeCustomerOFACSearch(cust, requestID); err != nil {
+			moovhttp.Problem(w, err)
+			return
+		}
+		result, err := repo.getLatestCustomerOFACSearch(customerID)
+		if err != nil {
+			moovhttp.Problem(w, err)
+			return
+		}
+		if result.match > ofacMatchThreshold {
+			err = fmt.Errorf("customer=%s matched against OFAC entity=%s with a score of %.2f - rejecting customer", cust.ID, result.entityId, result.match)
+			logger.Log("ofac", err.Error(), "requestID", requestID, "userID", userID)
+
+			cust.Status = customers.Rejected.String()
+			if err := repo.updateCustomerStatus(cust.ID, customers.Rejected, "manual OFAC refresh"); err != nil {
+				moovhttp.Problem(w, err)
+				return
+			}
+
+			moovhttp.Problem(w, err)
+			return
+		}
+
+		w.WriteHeader(http.StatusOK)
+		json.NewEncoder(w).Encode(cust)
+	}
+}
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 2d2e9ed..557d368 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -129,6 +129,7 @@ func main() {
 	addCustomerRoutes(logger, router, customerRepo, customerSSNStorage, ofac)
 	addDisclaimerRoutes(logger, router, disclaimerRepo)
 	addDocumentRoutes(logger, router, documentRepo, getBucket(bucketName, cloudProvider, signer))
+	addOFACRoutes(logger, router, customerRepo, ofac)
 
 	// Optionally serve /files/ as our fileblob routes
 	// Note: FILEBLOB_BASE_URL needs to match something that's routed to /files/...
diff --git a/openapi.yaml b/openapi.yaml
index 31650b5..0424166 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -445,6 +445,44 @@ paths:
               schema:
                 type: string
                 format: binary
+  /customers/{customerID}/refresh/ofac:
+    put:
+      tags: [Customers]
+      summary: Refresh OFAC search for a given Customer
+      operationId: refreshOFACSearch
+      parameters:
+        - name: X-Request-ID
+          in: header
+          description: Optional Request ID allows application developer to trace requests through the systems logs
+          example: rs4f9915
+          schema:
+            type: string
+        - name: X-User-ID
+          in: header
+          description: Moov User ID
+          example: e3cdf999
+          schema:
+            type: string
+        - name: customerID
+          in: path
+          description: Customer ID
+          required: true
+          schema:
+            type: string
+            example: e210a9d6
+      responses:
+        '200':
+          description: OFAC search did not block customer
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Customer'
+        '400':
+          description: An error occurred when searching OFAC data
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
 
 components:
   schemas:


Test patch:
diff --git a/cmd/server/approval_ofac_test.go b/cmd/server/approval_ofac_test.go
index b5ee603..d5fd7de 100644
--- a/cmd/server/approval_ofac_test.go
+++ b/cmd/server/approval_ofac_test.go
@@ -5,8 +5,14 @@
 package main
 
 import (
+	"errors"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
 	"testing"
 
+	"github.com/go-kit/kit/log"
+	"github.com/gorilla/mux"
 	"github.com/moov-io/base"
 	client "github.com/moov-io/customers/client"
 	ofac "github.com/moov-io/ofac/client"
@@ -22,6 +28,18 @@ func createTestOFACSearcher(repo customerRepository, ofacClient OFACClient) *ofa
 	return &ofacSearcher{repo: repo, ofacClient: ofacClient}
 }
 
+func TestOFACSearcher__nil(t *testing.T) {
+	repo := createTestCustomerRepository(t)
+	defer repo.close()
+
+	ofacClient := &testOFACClient{}
+	searcher := createTestOFACSearcher(repo, ofacClient)
+
+	if err := searcher.storeCustomerOFACSearch(nil, "requestID"); err == nil {
+		t.Error("expected error")
+	}
+}
+
 func TestOFACSearcher__storeCustomerOFACSearch(t *testing.T) {
 	repo := createTestCustomerRepository(t)
 	defer repo.close()
@@ -52,3 +70,79 @@ func TestOFACSearcher__storeCustomerOFACSearch(t *testing.T) {
 		t.Fatal(err)
 	}
 }
+
+func TestOFACApproval__refresh(t *testing.T) {
+	logger := log.NewNopLogger()
+	router := mux.NewRouter()
+
+	customerID := base.ID()
+
+	repo := &testCustomerRepository{
+		customer: &client.Customer{
+			ID: customerID,
+		},
+		savedOFACSearchResult: &ofacSearchResult{
+			entityId: "142",
+			match:    1.0,
+		},
+	}
+	testOFACClient := &testOFACClient{}
+	ofac := &ofacSearcher{
+		repo:       repo,
+		ofacClient: testOFACClient,
+	}
+
+	addOFACRoutes(logger, router, repo, ofac)
+
+	w := httptest.NewRecorder()
+	req := httptest.NewRequest("PUT", fmt.Sprintf("/customers/%s/refresh/ofac", customerID), nil)
+	router.ServeHTTP(w, req)
+	w.Flush()
+
+	if w.Code != http.StatusBadRequest {
+		t.Errorf("bogus HTTP status: %d", w.Code)
+	}
+
+	repo.savedOFACSearchResult.match = 0.90 // match isn't high enough to block
+
+	w = httptest.NewRecorder()
+	router.ServeHTTP(w, req)
+	w.Flush()
+
+	if w.Code != http.StatusOK {
+		t.Errorf("bogus HTTP status: %d", w.Code)
+	}
+}
+
+func TestOFACApproval__refreshErr(t *testing.T) {
+	logger := log.NewNopLogger()
+	router := mux.NewRouter()
+
+	customerID := base.ID()
+
+	repo := &testCustomerRepository{
+		customer: &client.Customer{
+			ID: customerID,
+		},
+		savedOFACSearchResult: &ofacSearchResult{
+			entityId: "142",
+			match:    0.88,
+		},
+	}
+	testOFACClient := &testOFACClient{err: errors.New("bad error")}
+	ofac := &ofacSearcher{
+		repo:       repo,
+		ofacClient: testOFACClient,
+	}
+
+	addOFACRoutes(logger, router, repo, ofac)
+
+	w := httptest.NewRecorder()
+	req := httptest.NewRequest("PUT", fmt.Sprintf("/customers/%s/refresh/ofac", customerID), nil)
+	router.ServeHTTP(w, req)
+	w.Flush()
+
+	if w.Code != http.StatusBadRequest {
+		t.Errorf("bogus HTTP status: %d - %s", w.Code, w.Body.String())
+	}
+}