mirror of
https://github.com/luxfi/broker.git
synced 2026-09-24 00:25:18 +00:00
590 lines
17 KiB
Go
590 lines
17 KiB
Go
package compliance
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/luxfi/broker/pkg/provider"
|
|
"github.com/luxfi/broker/pkg/types"
|
|
"github.com/luxfi/broker/pkg/webhook"
|
|
"github.com/luxfi/compliance/pkg/onboarding"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// ssnHMACKey is the key used for HMAC-SHA256 hashing of SSNs.
|
|
// Must be set via SSN_HMAC_KEY environment variable (sourced from KMS).
|
|
var ssnHMACKey []byte
|
|
|
|
func init() {
|
|
key := os.Getenv("SSN_HMAC_KEY")
|
|
if key == "" {
|
|
if os.Getenv("BROKER_ENV") != "development" && os.Getenv("BROKER_ENV") != "" {
|
|
// Fatal: production requires a real HMAC key from KMS.
|
|
log.Fatal().Msg("SSN_HMAC_KEY is required in non-development environments (source from KMS)")
|
|
}
|
|
ssnHMACKey = []byte("test-only-not-for-production")
|
|
return
|
|
}
|
|
ssnHMACKey = []byte(key)
|
|
}
|
|
|
|
// applicationHandler manages the 5-step investor onboarding flow.
|
|
type applicationHandler struct {
|
|
store ComplianceStore
|
|
registry *provider.Registry
|
|
webhookStore webhook.Store
|
|
}
|
|
|
|
// newApplicationSteps delegates to the compliance library's canonical step definitions.
|
|
func newApplicationSteps() []ApplicationStep {
|
|
return onboarding.NewApplicationSteps()
|
|
}
|
|
|
|
// handleCreate starts a new onboarding application.
|
|
func (h *applicationHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
UserID string `json:"user_id"`
|
|
Email string `json:"email"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.UserID == "" {
|
|
writeError(w, http.StatusBadRequest, "user_id is required")
|
|
return
|
|
}
|
|
if req.Email == "" {
|
|
writeError(w, http.StatusBadRequest, "email is required")
|
|
return
|
|
}
|
|
|
|
app := &Application{
|
|
UserID: req.UserID,
|
|
Email: req.Email,
|
|
Status: AppDraft,
|
|
CurrentStep: 1,
|
|
KYCStatus: KYCPending,
|
|
AMLStatus: AMLPending,
|
|
Steps: newApplicationSteps(),
|
|
}
|
|
|
|
if err := h.store.SaveApplication(app); err != nil {
|
|
log.Error().Err(err).Str("user_id", req.UserID).Msg("application: failed to create")
|
|
writeError(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, app)
|
|
}
|
|
|
|
// handleGet returns an application by ID.
|
|
func (h *applicationHandler) handleGet(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
app, err := h.store.GetApplication(id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, app)
|
|
}
|
|
|
|
// handleGetByUser returns the application for a given user.
|
|
func (h *applicationHandler) handleGetByUser(w http.ResponseWriter, r *http.Request) {
|
|
userID := r.URL.Query().Get("user_id")
|
|
if userID == "" {
|
|
writeError(w, http.StatusBadRequest, "user_id query parameter is required")
|
|
return
|
|
}
|
|
app, err := h.store.GetApplicationByUser(userID)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, app)
|
|
}
|
|
|
|
// handleList returns all applications, optionally filtered by status.
|
|
func (h *applicationHandler) handleList(w http.ResponseWriter, r *http.Request) {
|
|
status := r.URL.Query().Get("status")
|
|
if status != "" {
|
|
writeJSON(w, http.StatusOK, h.store.ListApplicationsByStatus(ApplicationStatus(status)))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, h.store.ListApplications())
|
|
}
|
|
|
|
// isTerminalStatus delegates to the compliance library's canonical definition.
|
|
func isTerminalStatus(status ApplicationStatus) bool {
|
|
return onboarding.IsTerminalStatus(status)
|
|
}
|
|
|
|
// handleStep1 updates Step 1: Basic Info + Contact.
|
|
func (h *applicationHandler) handleStep1(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
app, err := h.store.GetApplication(id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
if isTerminalStatus(app.Status) {
|
|
writeError(w, http.StatusConflict, "application is in a terminal state and cannot be modified")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
FirstName string `json:"first_name"`
|
|
LastName string `json:"last_name"`
|
|
Email string `json:"email,omitempty"`
|
|
Phone string `json:"phone,omitempty"`
|
|
DateOfBirth string `json:"date_of_birth,omitempty"`
|
|
SSN string `json:"ssn,omitempty"` // will be hashed, never stored raw
|
|
AddressLine1 string `json:"address_line1,omitempty"`
|
|
AddressLine2 string `json:"address_line2,omitempty"`
|
|
City string `json:"city,omitempty"`
|
|
State string `json:"state,omitempty"`
|
|
ZipCode string `json:"zip_code,omitempty"`
|
|
Country string `json:"country,omitempty"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.FirstName == "" {
|
|
writeError(w, http.StatusBadRequest, "first_name is required")
|
|
return
|
|
}
|
|
if req.LastName == "" {
|
|
writeError(w, http.StatusBadRequest, "last_name is required")
|
|
return
|
|
}
|
|
|
|
app.FirstName = req.FirstName
|
|
app.LastName = req.LastName
|
|
if req.Email != "" {
|
|
app.Email = req.Email
|
|
}
|
|
app.Phone = req.Phone
|
|
app.DateOfBirth = req.DateOfBirth
|
|
app.AddressLine1 = req.AddressLine1
|
|
app.AddressLine2 = req.AddressLine2
|
|
app.City = req.City
|
|
app.State = req.State
|
|
app.ZipCode = req.ZipCode
|
|
app.Country = req.Country
|
|
|
|
// Hash SSN if provided -- NEVER store plaintext.
|
|
// Uses HMAC-SHA256 with a key to prevent rainbow table attacks on the
|
|
// small SSN keyspace (10^9). Key must come from KMS via SSN_HMAC_KEY env var.
|
|
if req.SSN != "" {
|
|
mac := hmac.New(sha256.New, ssnHMACKey)
|
|
mac.Write([]byte(req.SSN))
|
|
app.SSNHash = hex.EncodeToString(mac.Sum(nil))
|
|
if len(req.SSN) >= 4 {
|
|
app.SSNLast4 = req.SSN[len(req.SSN)-4:]
|
|
}
|
|
}
|
|
|
|
app.Status = AppInProgress
|
|
if app.CurrentStep < 2 {
|
|
app.CurrentStep = 2
|
|
}
|
|
markStepCompleted(app, 1)
|
|
|
|
if err := h.store.SaveApplication(app); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, app)
|
|
}
|
|
|
|
// handleStep2 updates Step 2: Identity Verification.
|
|
func (h *applicationHandler) handleStep2(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
app, err := h.store.GetApplication(id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
if isTerminalStatus(app.Status) {
|
|
writeError(w, http.StatusConflict, "application is in a terminal state and cannot be modified")
|
|
return
|
|
}
|
|
if app.CurrentStep < 2 {
|
|
writeError(w, http.StatusBadRequest, "step 1 must be completed before step 2")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Provider string `json:"provider"` // onfido, berbix, idmerit
|
|
VerificationID string `json:"verification_id"` // external verification ID
|
|
Status string `json:"status,omitempty"` // pending, verified, failed
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.Provider == "" {
|
|
writeError(w, http.StatusBadRequest, "provider is required")
|
|
return
|
|
}
|
|
|
|
// Create an identity record linked to this application.
|
|
ident := &Identity{
|
|
UserID: app.UserID,
|
|
Provider: req.Provider,
|
|
Status: KYCPending,
|
|
Data: map[string]interface{}{
|
|
"application_id": app.ID,
|
|
"verification_id": req.VerificationID,
|
|
},
|
|
}
|
|
if req.Status == "verified" {
|
|
ident.Status = KYCVerified
|
|
} else if req.Status == "failed" {
|
|
ident.Status = KYCFailed
|
|
}
|
|
|
|
if err := h.store.SaveIdentity(ident); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
|
|
app.KYCStatus = ident.Status
|
|
if ident.Status == KYCVerified {
|
|
markStepCompleted(app, 2)
|
|
if app.CurrentStep < 3 {
|
|
app.CurrentStep = 3
|
|
}
|
|
} else if ident.Status == KYCFailed {
|
|
markStepFailed(app, 2)
|
|
}
|
|
|
|
if err := h.store.SaveApplication(app); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, app)
|
|
}
|
|
|
|
// handleStep3 updates Step 3: Document Upload.
|
|
func (h *applicationHandler) handleStep3(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
app, err := h.store.GetApplication(id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
if isTerminalStatus(app.Status) {
|
|
writeError(w, http.StatusConflict, "application is in a terminal state and cannot be modified")
|
|
return
|
|
}
|
|
if app.CurrentStep < 3 {
|
|
writeError(w, http.StatusBadRequest, "step 2 must be completed before step 3")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Documents []struct {
|
|
Type string `json:"type"`
|
|
Name string `json:"name"`
|
|
MimeType string `json:"mime_type"`
|
|
Size int64 `json:"size"`
|
|
} `json:"documents"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if len(req.Documents) == 0 {
|
|
writeError(w, http.StatusBadRequest, "at least one document is required")
|
|
return
|
|
}
|
|
|
|
for _, d := range req.Documents {
|
|
if d.Type == "" {
|
|
writeError(w, http.StatusBadRequest, "document type is required")
|
|
return
|
|
}
|
|
if d.Name == "" {
|
|
writeError(w, http.StatusBadRequest, "document name is required")
|
|
return
|
|
}
|
|
// Validate MIME type.
|
|
if d.MimeType != "" && !allowedDocMIMETypes[d.MimeType] {
|
|
writeError(w, http.StatusBadRequest, "unsupported file type; allowed: pdf, jpeg, png, tiff")
|
|
return
|
|
}
|
|
// Validate file size (10 MB max).
|
|
if d.Size > maxDocumentContentSize {
|
|
writeError(w, http.StatusBadRequest, "document exceeds 10MB size limit")
|
|
return
|
|
}
|
|
|
|
doc := &DocumentUpload{
|
|
ApplicationID: app.ID,
|
|
UserID: app.UserID,
|
|
Type: d.Type,
|
|
Name: d.Name,
|
|
MimeType: d.MimeType,
|
|
Size: d.Size,
|
|
Status: "pending",
|
|
}
|
|
if err := h.store.SaveDocumentUpload(doc); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
}
|
|
|
|
markStepCompleted(app, 3)
|
|
if app.CurrentStep < 4 {
|
|
app.CurrentStep = 4
|
|
}
|
|
|
|
if err := h.store.SaveApplication(app); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, app)
|
|
}
|
|
|
|
// handleStep4 updates Step 4: Compliance/AML Screening.
|
|
func (h *applicationHandler) handleStep4(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
app, err := h.store.GetApplication(id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
if isTerminalStatus(app.Status) {
|
|
writeError(w, http.StatusConflict, "application is in a terminal state and cannot be modified")
|
|
return
|
|
}
|
|
if app.CurrentStep < 4 {
|
|
writeError(w, http.StatusBadRequest, "step 3 must be completed before step 4")
|
|
return
|
|
}
|
|
|
|
// Create an AML screening record for this application.
|
|
screening := &AMLScreening{
|
|
AccountID: app.ID,
|
|
UserID: app.UserID,
|
|
Type: "sanctions",
|
|
Status: AMLPending,
|
|
RiskLevel: RiskLow,
|
|
Provider: "manual",
|
|
}
|
|
if err := h.store.SaveAMLScreening(screening); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
|
|
app.AMLStatus = AMLPending
|
|
markStepCompleted(app, 4)
|
|
if app.CurrentStep < 5 {
|
|
app.CurrentStep = 5
|
|
}
|
|
|
|
if err := h.store.SaveApplication(app); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, app)
|
|
}
|
|
|
|
// handleStep5 updates Step 5: Review + Submit.
|
|
func (h *applicationHandler) handleStep5(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
app, err := h.store.GetApplication(id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
if isTerminalStatus(app.Status) {
|
|
writeError(w, http.StatusConflict, "application is in a terminal state and cannot be modified")
|
|
return
|
|
}
|
|
if app.CurrentStep < 5 {
|
|
writeError(w, http.StatusBadRequest, "step 4 must be completed before step 5")
|
|
return
|
|
}
|
|
if app.KYCStatus == "" || app.KYCStatus == KYCPending {
|
|
writeError(w, http.StatusBadRequest, "identity verification must be attempted before submission")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Confirmed bool `json:"confirmed"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if !req.Confirmed {
|
|
writeError(w, http.StatusBadRequest, "confirmed must be true to submit")
|
|
return
|
|
}
|
|
|
|
markStepCompleted(app, 5)
|
|
app.Status = AppSubmitted
|
|
app.SubmittedAt = time.Now().UTC()
|
|
|
|
if err := h.store.SaveApplication(app); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, app)
|
|
}
|
|
|
|
// handleReview allows an admin to approve or reject a submitted application.
|
|
func (h *applicationHandler) handleReview(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
app, err := h.store.GetApplication(id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
|
|
if app.Status != AppSubmitted && app.Status != AppUnderReview {
|
|
writeError(w, http.StatusBadRequest, "application must be submitted or under review")
|
|
return
|
|
}
|
|
|
|
// Extract reviewer identity from JWT context — never trust the request body.
|
|
// This prevents audit trail forgery (CRITICAL-3).
|
|
reviewer := r.Header.Get("X-User-Id")
|
|
if reviewer == "" {
|
|
writeError(w, http.StatusUnauthorized, "reviewer identity not found in token")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Decision string `json:"decision"` // approved, rejected
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.Decision != "approved" && req.Decision != "rejected" {
|
|
writeError(w, http.StatusBadRequest, "decision must be 'approved' or 'rejected'")
|
|
return
|
|
}
|
|
|
|
switch req.Decision {
|
|
case "approved":
|
|
app.Status = AppApproved
|
|
case "rejected":
|
|
app.Status = AppRejected
|
|
}
|
|
app.ReviewedBy = reviewer
|
|
app.ReviewedAt = time.Now().UTC()
|
|
|
|
if err := h.store.SaveApplication(app); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
|
|
// On approval, provision Alpaca brokerage account asynchronously.
|
|
if app.Status == AppApproved && h.registry != nil {
|
|
go h.provisionAlpacaAccount(app)
|
|
}
|
|
|
|
orgID := r.Header.Get("X-Org-Id")
|
|
switch app.Status {
|
|
case AppApproved:
|
|
webhook.Deliver(h.webhookStore, orgID, "account.approved", map[string]interface{}{
|
|
"application_id": app.ID,
|
|
"user_id": app.UserID,
|
|
"reviewed_by": reviewer,
|
|
})
|
|
case AppRejected:
|
|
webhook.Deliver(h.webhookStore, orgID, "account.rejected", map[string]interface{}{
|
|
"application_id": app.ID,
|
|
"user_id": app.UserID,
|
|
"reviewed_by": reviewer,
|
|
})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, app)
|
|
}
|
|
|
|
// provisionAlpacaAccount creates an Alpaca brokerage account for an approved application.
|
|
func (h *applicationHandler) provisionAlpacaAccount(app *Application) {
|
|
alpaca, err := h.registry.Get("alpaca")
|
|
if err != nil {
|
|
log.Error().Err(err).Str("app", app.ID).Msg("no alpaca provider for provisioning")
|
|
return
|
|
}
|
|
|
|
street := []string{}
|
|
if app.AddressLine1 != "" {
|
|
street = append(street, app.AddressLine1)
|
|
}
|
|
if app.AddressLine2 != "" {
|
|
street = append(street, app.AddressLine2)
|
|
}
|
|
|
|
country := app.Country
|
|
if country == "" {
|
|
country = "USA"
|
|
}
|
|
|
|
acctReq := &types.CreateAccountRequest{
|
|
Identity: &types.Identity{
|
|
GivenName: app.FirstName,
|
|
FamilyName: app.LastName,
|
|
DateOfBirth: app.DateOfBirth,
|
|
CountryOfTax: country,
|
|
},
|
|
Contact: &types.Contact{
|
|
Email: app.Email,
|
|
Phone: app.Phone,
|
|
Street: street,
|
|
City: app.City,
|
|
State: app.State,
|
|
PostalCode: app.ZipCode,
|
|
Country: country,
|
|
},
|
|
}
|
|
|
|
acct, err := alpaca.CreateAccount(context.Background(), acctReq)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("app", app.ID).Msg("alpaca account creation failed")
|
|
return
|
|
}
|
|
|
|
app.AlpacaAccountID = acct.ID
|
|
if err := h.store.SaveApplication(app); err != nil {
|
|
log.Error().Err(err).Str("app", app.ID).Str("alpaca_id", acct.ID).Msg("failed to save alpaca account ID")
|
|
return
|
|
}
|
|
log.Info().Str("app", app.ID).Str("alpaca_id", acct.ID).Msg("alpaca account provisioned")
|
|
}
|
|
|
|
// handleGetDocuments lists documents for an application.
|
|
func (h *applicationHandler) handleGetDocuments(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
// Verify application exists.
|
|
if _, err := h.store.GetApplication(id); err != nil {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, h.store.ListDocumentUploads(id))
|
|
}
|
|
|
|
// markStepCompleted delegates to the compliance library's canonical implementation.
|
|
func markStepCompleted(app *Application, step int) {
|
|
onboarding.MarkStepCompleted(app, step)
|
|
}
|
|
|
|
// markStepFailed delegates to the compliance library's canonical implementation.
|
|
func markStepFailed(app *Application, step int) {
|
|
onboarding.MarkStepFailed(app, step)
|
|
}
|